All notable changes to Argus are documented here. Format loosely follows Keep a Changelog, and the project adheres to Semantic Versioning.
0.13.0 — 2026-09-16
Added
- The closed-issue corpus is part of the test suite.
test/corpus/pairs.exslists sixteen issue pairs;Argus.CorpusTestclones and compiles each tree once intoARGUS_CORPUS_DIRand asserts the rule's finding is present before the fix and absent at it.mix argus.corpus fetch|tallywarms the cache and tallies titles across the trees;mix argus.pinsregenerates the per-analysis input pins, now a data file (test/argus/analysis_inputs.exs) rather than a map in the test.
Changed
- Schema version 34.
catch_taggains aclasscolumn and is emitted per catching clause rather than per handler region, so a rule can ask "is there an:exitclause for:shutdown" instead of "does:shutdownappear anywhere".rescue Xnow counts as a tested clause with tagX(the__struct__read beforeException.normalize/3is a projection of the reason), soets_read_outside_owneraccepts only a rescue that would catch ArgumentError as a guard.catch_handleris dropped (no rule read it).matches_downnow means a clause head, not any comparison in the body. call_never_replied's walker keys revisits on thefrom/event alias sets too, so a block re-entered withfromelsewhere is not pruned.handle_info_partialneeds a late-message source in reach of the process's callbacks: a timedGenServer.call(its late reply), aTask.async, a timer (send_after,send_interval), a subscription, a message the process sends itself, or caller-supplied code run through a closure orapply(gen_stage#238's producer ran the user's stream). New factmailbox_writer(id, func, kind). A partial handle_info with nothing that writes the mailbox is a style note and stays quiet.ets_read_outside_ownerfollows a table name through parameters:defp fetch(table, key), do: :ets.lookup(table, key)called with a literal is a read of that table by the caller. New factets_op_param(id, pos).blocking_recv_in_initalso reports areceivewith noafteron init's path.foreign_dynamic_childrenalso counts tasks started under another tree's Task.Supervisor (dynamic_childrecords the child as"Task"), and no longer reports a start function that lives in the supervisor's own module (Postgrex.TypeSupervisor.start_server/2): that tree is offering a shared service.terminate_calls_siblingno longer reports a call under acatchthat takes the:noprocexit (oban's own fix), and a quiet-shapes fixture pins the nearest non-bug neighbour of every rule from the issue-mining pass.callback_totalmeans a clause accepts every message, whatever it demands of the state:handle_info(msg, {stack, continuation})is a catch-all (gen_stage's fix for #238), where the old check required a clause that accepts every argument.- Tag resolution of dynamic call targets is stricter and shows its
work. A tag is no longer attributed by uniqueness when it is generic
(
:get,:stop,:state, ...) or when somehandle_info/2also matches it; when several modules handle a tag, the one the caller's module refers to statically (its start_link, its API) wins. Cycle edges (call_cycle_path) and chains (timeout_chain_risk) carry a new column,"tag"or"static", and their findings say when an edge is inferred from a tag. Both are positional output changes for readers of those relations.
0.12.1 — 2026-09-16
Fixed
shutdown_safety'sforeign_dynamic_childrenandets'sets_read_outside_ownerattribute code to the process whose callbacks reach it, not to the module it sits in: a plain helper module that starts children for a GenServer in the same tree (encore's canon) is no longer reported. The closures run backwards from the start/table functions, so their size is bounded by those functions, not by every process's reach. New clientlib relationprocess_entry(mod, func).gen_statem'scall_never_repliedno longer reports a clause that parksfromat the head of a tuple (pending: {from, expected}) or reads it from a register the event was saved to.
0.12.0 — 2026-09-16
Added
Dynamic call targets resolved by message tag. Most
GenServer.calls target a pid or a name held in state, which the extractor reports asdynamic; on horde that hid a deadlock cycle whose every edge was such a call (elixir-horde/horde#217). Stage 0 now stagescall_tag— the tuple tag or atom each call/cast sends — and the clientlib attributes a dynamic-target call to the one GenServer module whose handler discriminates on that tag (tag_resolved_call; ambiguity attributes nothing).call_cycle,sync_call_in_init,timeout_chain,process_bottleneck,one_for_one_couplingandsupervisionsee the edges. Stage 0 writes a fourth file,call_tag.facts.error_handling'shandle_info_partial(:info): a GenServer or GenStage whose handle_info/2 matches specific messages and has no catch-all, even when nothing in the module invites runtime messages — a library's late reply crashed a Flow producer (gen_stage#238), a restart loop re-sent a start-up message (cachex#314), a stray message killed a process manager (commanded#332). The warning-gradehandle_info_without_catchallnow also covers GenStage.shutdown_safety'sterminate_calls_sibling(:warning): terminate/2 synchronously calls a sibling child of the same supervisor, which the supervisor may already have stopped (oban#21: the Watchman pausing its Producer,:noprocinside terminate/2).shutdown_safety'sforeign_dynamic_children(:warning): a process starts children under a DynamicSupervisor in another tree and its terminate/2 does not stop them, so they outlive it (the shape behind postgrex#763; that instance passes the supervisor through a GenServer message and is not yet resolved).supervision'sconsumer_supervisor_permanent_child(:warning): a ConsumerSupervisor child template withrestart: :permanent, which restarts every child that finishes its event (gen_stage#195). The extractor now reads ConsumerSupervisor trees.supervision'sdual_restart_authority(:warning): a process starts a permanent child under a DynamicSupervisor, monitors it, and starts it again from its :DOWN handler, so a child that stops on a semantic error crash-loops under two restart authorities and takes the tree with it (redix#334). New factchild_spec_restart(mod, restart)records what a module's own child_spec/1 declares, so a:temporarychild is exempt.supervision'spost_start_initialization(:info): shared state (a persistent_term, an ETS row, application env) written only afterSupervisor.start_linkreturned, while the children are already running (phoenix#5981). New factpost_start_call(func, site, callee).error_handling'spartial_noproc_catch(:warning): a peer call wrapped incatch :exit, {:noproc, _}with no clause for{:shutdown, _}— the peer stopping mid-call is the same condition and crashes the caller (phoenix_live_view#4359).distributed'serpc_transport_unhandled(:warning): a rescue around:erpc.callthat unwraps{:exception, _, _}in acasewith no clause for{:erpc, reason}, so a node going away is a CaseClauseError (nebulex#140).ets'sets_read_outside_owner(:info): a table created by a process's callbacks with no heir, read by a function its callbacks do not reach — the module's API, run in callers — with no rescue; during the owner's restart the read raises in the caller (redix#338).sync_call_in_init'sblocking_recv_in_init(:warning): init/1 reaches a:gen_tcp/:sslrecvwith:infinity, following the timeout through wrapper parameters (postgrex#746).New facts behind those:
catch_handler,catch_total,catch_tag,catch_falls_throughandtry_call— what atryhandler catches, from a path walk over its clauses (Argus.Extractors.ErrorHandling.CatchClauses).gen_statem'scall_never_replied(:warning): a{:call, from}clause that returns without a reply action, without postponing, and without keepingfrom— the caller of:gen_statem.call/2waits:infinityby default (sneako/finch#213: a cancel while disconnected blocked for days). Schema 32 addsstatem_call_unreplied.unsafe_task'syield_on_linked_task(:warning): a task started withTask.async/Task.Supervisor.asyncand collected withTask.yieldoryield_manyin a process that does not trap exits — the link delivers a crash before the{:exit, _}branch can run (whatyouhide/redix#317). Andlinked_task_in_library(:info):Task.asyncin a plain library function, linked to whichever process called it (elixir-ecto/ecto#2246).
Changed
Schema version 33: eight layer-2 relations added —
child_spec_restart,post_start_call,matches_down(a function that compares something to:DOWN, emitted for every function so a gen_statem's private :info helpers count),catch_handler,catch_total,catch_tag,catch_falls_throughandtry_call; no existing relation changed.call_cycle,process_bottleneck,timeout_chain,sync_call_in_initandmessage_contractno longer readdef_use,tuple_literalorliteral_value: the tag comes staged. Those are positional relations, so a body edit no longer re-solves them.
Fixed
message_contractno longer reports a dynamic-target call whose tag another module's handler discriminates on as a message the caller sends itself.
0.11.0 — 2026-09-16
Changed
- The package is
panoptes(Argus Panoptes;argusis taken on Hex) and so is the OTP application: depend on{:panoptes, "~> 0.11"}. The modules keep theArgusnamespace. Anything that named the application (Application.spec(:argus, :vsn),:code.priv_dir(:argus)) names:panoptesnow.
Removed
mix argusandscripts/analyze_project.exs, withArgus.Report. Scry's Mix compiler is the way to run the analyses over a project — incremental, and reporting through compiler diagnostics; this package is the engine and its in-VM API.mix argus.gen.dlstays: it is a maintainer task for this tree.
0.10.0 — 2026-09-16
Added
Argus.Symbols: an interning table for fact symbols, andArgus.Pipeline.extract/2withformat: :interned— rows as tuples of symbol ids and integers, interned in the extracting worker against thesymbols:table the caller owns.Argus.Facts.intern/2,materialize/2anddecode/2convert between the raw, interned and typed forms. On a 226k-row module the rows take 9 MB of heap instead of 39 MB (3 MB instead of 24 MB serialized), an ETS round trip is 9 ms instead of 60 ms, and typed decoding — instruction IDs parsed once per symbol and cached on the table — takes 0.36 s instead of 1.2 s. The table is pluggable (Argus.Symbols.Store) so a consumer that persists rows can persist the ids' meaning with them; the default is ETS.
0.9.1 — 2026-09-16
Schema 31: whereis_call gains a checked column.
Fixed
process_registry'swhereis_raceno longer flags everyProcess.whereis/1. The extractor reads the straight-line code after the call for a comparison against nil/:undefined, a type test or aselectthat lists nil, and records the result in the newcheckedcolumn; only unchecked results are findings.case Process.whereis(n) do nil -> ...(nerves_hub_link's error-report collector) was a false positive.atom_safety'scode_injection_riskskipsSystem.cmd/2,3with a literal command whatever its arguments: argv is never shell-parsed, so caller data in it is not code execution. A literal shell or interpreter (sh,bash,python, ...) with non-literal arguments still counts.System.cmd("free", [])was flagged because the empty argument list is the atomnilin bytecode, which the old guard did not accept as static.
0.9.0 — 2026-09-12
Changed
- Fact extraction streams each module's rows to the
.factsfiles as it completes instead of merging the whole program in memory first. On a 2,400-module project the extracting VM peaked at 5.8 GB; the whole fact set never exists in memory at once now. Argus.Analysis.extract_facts/3stages only the relations a Souffle program reads. The sixteen layer-1 relations that exist for the in-process control-flow and dataflow passes (instruction,next,def,use,move, ... —Argus.Schema.in_process_only/0) are written as empty files; they were three quarters of the fact volume.Argus.Pipeline.run/3still writes everything unless givenrelations:.- No built-in analysis reads
call_reachableany more. The closure is quadratic in the call graph (17M rows from 100k edges on the same project, ~500 MB in every solve that touched it), and every question the analyses asked of it — which functions reach a sync dependency, a supervisor call, an exit, an effect, a sink — is answered by propagating overcall_edgefrom the few functions that matter. The clientlib'sreaches_sync_dep,reaches_async_dep,reaches_sync_dep_timeout,module_reaches,stateful_module_depandgenserver_sync_apiare derived that way (new helpersreaches_module,reaches_sync_caller_in), and each analysis that joined the closure directly has its own root-driven relation. Same rows out; the eighteen solves that took 450-690 MB and 12-17 s each now take under 60 MB and under 2 s.same_process_reachesis gone (monitor_leakpropagates over non-closure edges instead);call_reachablestays declared for custom programs. Argus.Findings.run/2caps concurrent Souffle solves at four by default (:concurrencyoverrides it). Every solve holds its own copy of the call graph's closure, so running one per scheduler multiplied a project-sized footprint by the core count.
Fixed
mix argusremoves its work directory when it finishes. Each run left the staged facts (hundreds of megabytes on a large project) in the temp directory.
0.8.1 — 2026-09-12
Changed
mix argusandscripts/analyze_project.exsfail before extracting anything when thesoufflebinary is missing, with a message that says where to install it (Argus.Souffle.not_found_message/0).
0.8.0 — 2026-09-12
Schema version 29. A consolidation release: nothing an analysis reports changes unless a section below says so.
Changed
- Schema version 30:
tuple_literalis a layer-1 fact emitted byArgus.Pipeline.Emit(it is generic bytecode; the extractor that produced it is gone), andrpc_call's timeout column spells:infinityas-1and an unreadable value as0, assync_call_timeoutalways has. - One rule vocabulary.
priv/dl/clientlib/calls.dldefinessync_dep,reaches_sync_dep(with async and timeout twins),stateful_module_depandsame_process_reachesonce;callbacks.dldefinesotp_callback,init_function,handler_functionandterminate_callback;behaviours.dlcarriesprocess_behaviourwith itsloop,gen_server_likeandterminatingkinds;supervision.dlgainsstarts_before.otp.dlis the one prelude. Eleven analyses had each derived "this function waits on that module" by hand and no two agreed; the analyses shrink onto the shared definitions and every encore golden is byte-identical.resolved_calls.dladds the self-directed rows tosync_depfor analyses that accept value flow;sync_call_targetis gone. - One extraction pass. The pipeline indexes every call site once per
module (
Argus.Extractor.CallSites) and attaches the per-function control-flow graphs; extractors filter the index (Helpers.each_remote_call/3) instead of each walking the instruction stream, and walk control flow throughArgus.Cfg.Walkinstead of three private copies of the loop.Helpers.return_shapes/1replaces six return-tuple scanners;Argus.Extractor.Dispatchreads clause heads. Argus.Extractors.ApiCallsis one table-driven extractor for every "call to a known API, with an argument read back" fact — the process calls (sync_call,async_cast,sup_call,sync_call_timeout), atom safety, ports and distribution. It replaces the AtomSafety, Ports, Distributed and GenEvent extractors and the call tables in OTP.Argus.Extractorgainsrelations/0;Argus.ExtractorRelationsTestholds every declaration to the schema and to the rules.Argus.Analysis.run/3filters its results to the analysis's declared outputs and removes its work directory;Argus.Souffle.run/3removes the output directory it created;Argus.Findings.run/2takes a:facts_dirto evaluate an existing extraction and cleans up its own.scripts/analyze_project.exsextracts once for every analysis instead of once per analysis plus twice more.
Fixed
ets.dlmatched the declared behaviour string, so an Erlang-behaviour(application)module owning a table was reported as unprotected.- Via-named call targets (
"via:Registry"), which no rule can resolve to a module, no longer reach findings as if they were one. - gen_statem state-function IDs were minted from
String.to_atom/1of the module's inspected name and were unresolvable.
Removed
- The autoresearch loop (
Argus.Autoresearch,mix argus.autoresearch,.autoresearch/) and the batch scriptsscripts/harness.exsandscripts/analyze_all.sh.scripts/analyze_project.exsremains. Argus.Origins,Argus.ResolutionandReport's:factsoption,Argus.Pipeline.read_facts/1,Argus.Facts.canonicalize/1,Argus.Schema.{fetch!,arity,field_names,souffle_decl}/1: no callers anywhere in the workspace.Argus.Souffle's:fallback_rulesoption and the_argus_moderesult key: nothing passed the option, so every result was"precise".priv/dl/clientlib/cfg.dl: no analysis readcfg_edge; the conditional-call question it existed for is answered byArgus.Cfgat extraction time.- Twelve relations no rule read:
module_info,import_ref,recv_end,make_fun(superseded byclosure_def),tuple_field_access,unhandled_op,deferred_reply,delayed_message,sync_call_via,via_tuple,registry_op,gen_event_handler, with the extractor code that produced them.
0.7.3 — 2026-09-11
Changed
- Elixir requirement lowered to
~> 1.18; OTP 28 remains required. CI tests both 1.18.4 and 1.19.4.
0.7.2 — 2026-09-11
Changed
init_waits_on_blocking_servercounts only unbounded operations in the callee's handler —terminate_child,stop,restart_child,delete_child, or a GenServer.call with:infinity. A handler thatstart_childs is bounded by the child's init; db_connection's fixed Watcher is that shape and was still reported.permanent_child_stops_normallyis:info: the restart is certain, whether it is wanted is not (Phoenix.Config and Swoosh's storage manager stop themselves on purpose).rest_for_one_orphaned_childrenis:warningwhen the call names the holder and:infowhen the holder is inferred.
0.7.1 — 2026-09-11
Schema version 28.
Added
monitor_ref_dropped(id, func)— the refProcess.monitor/1returned is discarded at the call site, read from the instructions that follow.monitor_leak:monitor_ref_discarded(:info) — a server callback drops the ref of a monitor it establishes, so nothing can ever demonitor it (Phoenix PubSub's Local, for every subscriber).
Changed
monitor_leak's lifetime rules reach helpers through closures (Redix's cluster manager monitors inside anEnum.reducefun) and count a removal path anywhere in the module, not only in callbacks.
0.7.0 — 2026-09-11
Schema version 27. Every change below comes out of replaying 42 historical OTP bug fixes from the Hex corpus (oban, phoenix_pubsub, db_connection, redix, postgrex, finch, bandit, thousand_island, cachex, libcluster, broadway, gen_stage, sentry, swoosh) against their pre-fix commits: argus found 5 at the bug site; the rest name the gap each entry closes.
Added (facts)
sup_call(id, func, api, op, target)— synchronous management calls into supervisor processes (Supervisor.start_child/2,DynamicSupervisor.terminate_child/2,Task.Supervisor.async_nolink/2, ...). Every one is a GenServer.call underneath but none named a GenServer module, sosync_callnever saw them.:gen_statem.call/2,3,GenStateMachine.call/2,3andGenStage.call/2,3are sync calls (the gen_statem default timeout is:infinity, recorded as such); theircasts are async casts.callback_tagandcallback_totalcoverhandle_info/2.callback_stop_reason(id, func, reason)— the literal reason of a{:stop, reason, ...}callback return.callback_timeout(id, func, callback, timeout_ms)— the literal integer timeout of an{:ok, state, ms}/{:noreply, state, ms}/{:reply, reply, state, ms}return.callback_returnnow also reads a return the compiler folded into a single literal.statem_event_clause(mod, func, event_type),statem_info_catchall(mod, func),statem_event_catchall(mod, func)— what a gen_statem state function or handle_event/4 matches on its first argument, and whether some clause accepts:infowith any content / any event at all, read from the clause dispatch.Connection,Postgrex.SimpleConnectionandPostgrex.ReplicationConnectioncanonicalise toGenServer, so every rule about GenServer callbacks applies to modules declaring them.
Changed (supervision extraction)
- A tuple-spec child whose module is
Keyword.get(opts, key, Default)orMap.get(opts, key, Default)resolves toDefault(Oban's queue supervisor builds its producer this way; the child was dropped). - A child list built from cons cells is walked from its outermost cell, so positions follow source order when literal and runtime elements are interleaved. Before, a runtime element was filed wherever its tuple happened to be built, and every position-reading rule saw the wrong order.
Fixed
- gen_statem state-function IDs were minted from
String.to_atom/1of the module's inspected name —:"A.B", notA.B— so every transition and timeout site instate_functionsmode was unresolvable.
Added (analyses)
callback_receivecanonicalises behaviour names, so a module declaring@behaviour :gen_statemis a callback loop (Redix's connection init waited on a bare receive, unreported).error_handling:trap_exit_without_exit_clause— traps exits, has a handle_info/2, no clause matches{:EXIT, ...}(Bandit's HTTP/1 handler);handle_info_without_catchall(:info) — a GenServer that monitors or traps exits defines handle_info/2 without a catch-all.supervision:permanent_child_stops_normally— a permanent child returns{:stop, :normal | :shutdown, ...}and is restarted (Phoenix PubSub's tracker shards on graceful permdown).deferred_startup_deadlock:init_timeout_deferral(:info) — init/1 returns{:ok, state, timeout}; any earlier message cancels the deferred work (libcluster's Gossip strategy).monitor_leak: the timed wait and the flush may sit a call below the monitor in the same module (Finch's HTTP/2 response loop); two lifetime heuristics at :info —monitor_never_released(monitors from callbacks, removes bookkeeping entries, never demonitors: Postgrex's Parameters server) anddeliberate_termination_while_monitored(terminate_child / GenServer.stop on a monitored pid without demonitoring: Oban's producer on pkill, Redix's cluster manager).ets:ets_write_only_table(:info) — a named table inserted into outside init/1 and never deleted from (Sentry's check-in ID mapping).supervision:rest_for_one_orphaned_children— a later child starts processes inside an earlier sibling (Oban's producer running jobs under the queue's Task.Supervisor), which survives the owner's restart.sync_call_in_init:sup_call_in_init(:info) — init/1 reaches a supervisor management call (Broadway's server, Oban's Midwife);init_waits_on_blocking_server— a call from init that the tree-order argument accepts, into a server whose handler blocks on a supervisor op or a GenServer.call of its own (db_connection's Watcher).gen_statem:state_missing_info_catchall— a state function has no:infocatch-all while sibling states do (Redix's Cluster.Manager);statem_timeout_unhandled(:error) — a:timeout/:state_timeoutaction is armed and no clause matches that event type (Postgrex's SimpleConnection wrote the handler as(:info, :timeout, ...)).
0.6.1 — 2026-09-11
Changed (supervision extraction)
- Trees defined outside
Supervisormodules are extracted: the function that callsSupervisor.start_link/2orSupervisor.init/2is the tree definition, whoever its module is (Broadway'sTopologyGenServer, Cachex'sstart_link/1). - Child specs are followed through helpers to depth 3 and into the
closures a function creates, so a
for/Enum.mapcomprehension that builds one spec per element (for i <- 0..n, do: %{start: {Producer, ...}}) contributes its module. Breadth-first, so the order approximates construction order. - A strategy inside a runtime-built option list (
[name: name(config), strategy: :rest_for_one]) is read from the cons cells. {GenServer, :start_link, [Mod, ...]}map specs resolve toMod, and a spec that resolves only to the behaviour module (GenServer,Agent,Task) is dropped rather than recorded as a child.- Tuple-shaped specs are only trusted when the tuple flows into a list,
is returned, or is passed to
Supervisor.child_spec/2, only at the tree function and its direct helpers, and only for Elixir modules; bare-atom and cons-cell children must be Elixir modules too. Before this, the deeper walk also swept up{GenStage.DemandDispatcher, opts}options and:supervisor/:queuetags as children.
On the surveyed corpus this recovers Broadway's topology (RateLimiter
before ProducerStage under rest_for_one, so the producer's init call
is a proven safe sibling), ThousandIsland's acceptor pool, Phoenix.PubSub's
PG2 and Tracker shards, and Oban's Nursery and queue supervisors, all of
which extracted empty or not at all.
0.6.0 — 2026-09-11
Changed (schema version 26 — conditional calls)
conditional_call(id): a call instruction whose basic block is control-dependent on a branch in its function, derived per module fromArgus.Cfg's post-dominator tree next todef_use. Positional likedef_use, and for the same reason emitted rather than folded intoremote_call.- Stage 0 derives
unconditional_call_edge(caller, callee)— the call graph restricted to pairs with at least one site that runs on every path — alongsidecall_edgeandcall_site. Consumers that project fact directories supply it the same way. sync_call_in_initgains akindcolumn:conditionalwhen every route frominit/1to the sync call passes through a branch-guarded site in init (postgrex'ssync_connect: true, goth'sprefetch: :sync, broadway'sif rate_limiter),unconditionalotherwise. Conditional rows are titled "init/1 can block", and the analysis reads only stage-0 output for the distinction — no instruction-keyed relation enters its input set.
0.5.1 — 2026-09-11
Precision and cutoff work from installing scry on the 24 most-downloaded Hex packages that ship supervision trees (16 findings, 1 of them actionable).
Changed (findings)
one_for_one_couplinggrades by mechanism. A newkindcolumn (call|cast) says whether the caller ever waits on the sibling. Cast-only couplings — tzdata'sReleaseUpdater → EtsHolder, sentry'sScheduler → ClientReport.Sender— cannot hold a stale reply, pid, or monitor, so they are:info("One-way coupling under one_for_one") rather than a warning asserting a consequence that does not follow. The anchor also follows the witness's local calls down to the instruction that reaches the sibling instead of stopping at the witness's head.sync_call_in_initis:info. Its remaining rows are exactly the cases where the callee's position could not be established (child specs built at runtime, calls behind an opt-in option such as postgrex'ssync_connector goth'sprefetch: :sync); the proven startup deadlock stays an:errorasinit_deadlock_risk.unsafe_tasksuppresses leaked-task findings in any module that defineshandle_info/2, not only the behaviours it could name.Phoenix.Presenceconsumes itsTask.Supervisor.asyncreplies in thehandle_info/2thatPhoenix.Trackerinvokes and was reported.
Changed (extraction)
- Literal facts drop location metadata. Logger macros embed
file:andline:in their metadata keyword, so a comment added above aLogger.warningchanged aliteral_valuerow and re-solved every analysis reading literals in the incremental consumers. Keywords and maps carrying both:fileand:linelose those two keys before they are formatted; nothing else about the literal changes.
0.5.0 — 2026-09-11
Added (findings carry their remediation)
Findings gained two additive fields for remediation-quality rendering:
:at_label (what the anchor line IS — "supervision tree defined here" —
so a renderer that excerpts source annotates the anchor with something
other than a repeat of the title) and :help (resolution guidance, one
string per suggestion). Both default (nil / []), every existing
finding/2 builder is shape-compatible, and Findings.new/4 validates
the new options loudly.
The six default-set analyses (deferred_startup_deadlock,
one_for_one_coupling, supervision, sync_call_in_init, unlinked_spawn,
unsafe_task) now populate both fields; remediation sentences that
previously lived inside detail prose moved into help, so details
state the problem and help states the fix. The remaining analyses are a
follow-on tranche.
one_for_one_coupling findings anchor at the coupling call itself. Stage
0 now derives call_site(id, caller, callee_mod) — calls into
project-defined modules plus the GenServer.call/cast a function
performs — alongside call_edge, and the output relation gained a
sixth site column: the instruction that calls the sibling (or the
GenServer call the witness performs), falling back to the witness
function ID so Findings.at_site/2 always has an anchor. The extra
stage-0 relation keeps remote_call out of the analysis's own input
set, so its projection stays cheap.
Autoresearch loop — the tooling layer that turns the 0.4.0 measurement surface into an iterative improvement workflow. Measure a corpus, baseline the results, edit an extractor, re-measure, diff, accept or revert, repeat. Inspired by pi-autoresearch's event-log + living-doc pattern, adapted for Argus's multi-dimensional categorical metrics.
Removed
Argus.Schema.Pin is gone, along with the per-consumer version ranges
it gated. Every consumer is a path or tagged-git dependency whose own
suite exercises the columns it reads, so the pin only ever added a
compile error ahead of a test failure — at the cost of one widening
commit per consumer per schema bump (gloss's history was mostly those).
@schema_version and Argus.SchemaVersionTest stay: the number is
what scry's and planchette's environment fingerprints and encore's
goldens key on, and the digest test is what keeps it honest.
Fixed (purity, found by dogfooding)
Annotating argus itself surfaced four problems in the analysis, three of them in the effect model.
Kernelwas listed as a pure module. It is not, and this was a soundness hole. Most of Kernel inlines to BIFs and never survives as a remote call, so the entries that DO survive are exactly the dispatching ones:inspect/1andto_string/1go through a protocol, which is user-extensible code. Kernel also exportssend/2,spawn/1,exit/1,apply/3andself/0. Treating it as pure meant a function callinginspect/1could be reported verified while transitively running arbitrary user code — the exact failure the analysis exists to prevent.Argus.InstrId.func_id/2was one such false verification, and correctly demotes to unprovable now.Pathwas listed impure wholesale, soPath.join/2— pure string manipulation — was reported as a filesystem effect. Only the handful that consult the filesystem or the current directory (wildcard,expand,absname,relative_to_cwd,safe_relative_to) belong there.Purity did not compose. A call to a function carrying its own
@pure truewas treated as unknown, so nothing that used your own pure helpers could ever be verified — which is most of what pure code does. The declaration is now trusted at the call site and verified separately at the definition, the bargain every contract system makes.apply/3with a literal MFA is now resolved rather than given up on.applyis only opaque when M and F are genuinely unknown; with constants it is a static call wearing a disguise.resolve_register/3already reconstructs register contents, so the analysis looks before it shrugs. The payoff runs both ways:apply(Enum, :reverse, [l])verifies, andapply(IO, :puts, [x])is a proven violation namingIO.puts/1instead of an unprovable shrug.
Also recorded: Elixir compiles x.field — dot access on a value it cannot
prove is a map — to a helper that reads a map field OR calls x.field() as
a remote function. That second branch is a dynamic dispatch behind ordinary
syntax, so such code is not statically pure. Argus.Lines.resolve/2 now
uses Map.fetch!/2, which is both provable and clearer on a plain map.
Dogfood state: 11 verified, 0 violated, and six functions unprovable —
every one of them because it reaches Kernel.inspect/1 or
String.Chars.to_string/1 to format an atom. That is the honest answer,
and the annotations are deliberately left in place to document where the
limit is.
Added (analysis)
Added (analysis)
purityandArgus.Purity— declare a function free of side effects, and have the claim mechanically checked.defmodule Money do use Argus.Purity @pure true def add(%Money{cents: a}, %Money{cents: b}), do: %Money{cents: a + b} endThis is a different shape from every other analysis here. The others look for bugs nobody claimed were absent; this one verifies a claim the author made, so a finding says "you declared this pure and here is the call that makes it not" rather than "this looks suspicious".
It is therefore the first analysis that has to be sound rather than merely useful. A missed supervision smell costs a warning; a purity check that reports "verified" for a function that writes to ETS has actively misled someone into depending on it. So there are three outcomes:
purity_violated— reaches a known observable effect, named by category (io, process, process_dict, ets, port, node, time, random, network, code_loading) and attributed to the function that performs it.purity_unprovable— reaches something that cannot be accounted for: a call through a fun value orapply, a protocol dispatch (whose implementations are an open set no table could enumerate), or a call the effect model has no entry for.purity_verified— everything reachable is known effect-free. Emitted deliberately: a contract is only worth having if you can tell it was actually checked.
The declaration travels as a persisted module attribute, so the contract is read out of the beam rather than the source and cannot drift from the code it describes. No macro wraps the function, so the emitted code is byte-for-byte what it would have been.
Effects in closures the function builds are caught for free: the compiler lifts the lambda and argus already records a
closure_defedge, so@pure true def each(l), do: Enum.each(l, &IO.puts/1)is a violation attributed to the lifted-each/1-fun-0-.The effect model (
Argus.Purity.Effects) lives in Elixir rather than in rules — it is a large table that wants doctests, and expressing it as Datalog would mean string surgery, which is what produced the partial-functor unsoundness fixed in v9.Dogfooded:
Argus.InstrIdcarries@pure trueon its public API. Six functions verify; two are unprovable because they reachString.Chars.to_string/1, and that is the correct answer — protocol dispatch runs whichever implementation the argument's type provides, which is ordinary user code.Requires schema v13:
send_msgandmake_fungaincaller, the newdynamic_callrecords calls through a fun value orapply, and Layer 2 gainspure_contract,impure_call,protocol_dispatchandunknown_call.callback_receive— a barereceiveinside an OTP callback.An OTP process is already in a receive loop its behaviour owns. A
receivein a callback runs inside that loop and selectively consumes from the same mailbox:{:system, _, _}(how:sys.get_stateand the whole debug surface reach the process),{:EXIT, _, _}when trapping, and every in-flight monitor's{:DOWN, ...}. Messages it does not match stay queued and are re-scanned by every later receive. Without anafterit can block forever, so shutdown waits out the child timeout and brutal-kills. No compiler or dialyzer diagnostic covers this.Two suppressions, both added because verification demanded them rather than in anticipation:
- Closure edges are subtracted.
call_edgetreats closure construction as a call so reachability follows into lambdas; areceiveinsidespawn(fn -> ... end)runs in the spawned process and is not this bug. - The
cancel_timerflush idiom is excluded.cancel_timer/1returning false means the message was already sent, so the receive is guaranteed to match. Both blocking receives in the first corpus sweep were this idiom — without the suppression it would have been the analysis's entire output on that project.
Requires schema v12:
recv_startgainscaller, andblocking, which distinguishes a receive whose empty-mailbox block ends inwait(no timeout) from one ending inwait_timeout. That is only visible by following a label to another instruction, so the emitter resolves it — verified against OTP itself, where:gen_server:loop/5comes out bounded and:timer:interval_loop/5blocking.- Closure edges are subtracted.
request_surface— dangerous operations reachable from callbacks that receive external data, rather than from "any exported function".atom_safetyalready finds unsafe atom creation, unsafe deserialization and dynamic evaluation, but gates them on reachability from some export, which in a real application is nearly everything. It answers "is this code live", not "can an attacker reach it". This analysis starts from OTP callbacks that take request-shaped input —Plug.call/2, LiveViewmount/handle_params/handle_event,Phoenix.Channel.handle_in/3,Oban.Worker.perform/1, Broadway's message callbacks — identified by behaviour plus callback name and arity, which is information the beam already carries.Findings carry a proximity, and it is the difference between a report worth reading and a list of everything the app can reach:
direct(:error) — the sink is in the callback, operating on its own arguments, which ARE the request.adjacent(:warning) — one call away.transitive(:info) — a path exists; that a data flow exists is unproven.
The tiers are calibrated against hand-verified findings, not chosen a priori. On a corpus sweep every
directfinding was a true positive,adjacentwas mixed (one real unvalidated URL parameter, one database primary key), and everytransitivehit examined sourced its input from Postgres or Redis rather than the request — reached only because some LiveView loads those records. Reachability is not taint, and the analysis says so rather than pretending otherwise.
Changed (schema version 11 — no rule reads instruction)
unsafe_task asked "was this call's result pattern-matched?" by joining
instruction to itself and comparing two indexes:
instruction(id, func, sc_idx, _),
instruction(bid, func, bidx, _),
branch(bid, _, _),
bidx > sc_idx.That is a yes/no question about ordering, answered by dragging in the
largest and most volatile relation in the schema. It left unsafe_task as
the last analysis reading instruction, and by a wide margin the most
expensive one.
The emitter already knows every instruction's index, so it now answers the
question directly as call_followed_by_branch(id) — one row per call site
that has a later branch. The predicate is deliberately just as coarse as it
was (a branch anywhere later in the function counts, including one in an
unrelated clause), because moving where something is computed must not
change what it computes. Findings are byte-identical: 962 over 1259 beams.
No Datalog rule reads instruction any more. It is still emitted and
still used — Argus.Cfg, Argus.Dataflow and gloss all need it — but it no
longer gates any analysis's incrementality.
Measured (oban corpus for volume, sequin's 531 modules for time):
| before W1 | after v10 | after v11 | |
|---|---|---|---|
| serialized fact volume, all analyses | 416 MB | 247 MB | 160 MB |
unsafe_task input volume | 98 MB | 100 MB | 12 MB |
unsafe_task solve | — | 690 ms | 179 ms |
| slowest single analysis | — | 690 ms | 284 ms |
Sharpening the heuristic — asking whether the call's result register is
actually inspected, which Argus.Extractor.Helpers has the machinery for —
would change findings and is deliberately left as its own change.
Changed (schema version 10 — calls carry their caller)
Twelve of the fourteen instruction(...) uses in the rule corpus existed
only to recover a call's containing function from its instruction ID:
remote_call(id, ...), instruction(id, caller, _, _). That made
instruction — the largest relation in the schema, and one rewritten
whenever any function body changes, because an instruction ID is a raw
per-function offset — an input to stage 0 and therefore to every analysis
downstream of the call graph.
remote_call, local_call, bif_call, spawn_call and try_start now
carry a caller column; try_start also carries kind ("try" or the
older "catch"), which collapses two rules into one. Nothing about the
findings changes — 962 findings over 1259 beams, byte-identical.
What changes is how much has to cross the file boundary. Serialized fact volume for a full analysis run over the oban corpus:
| analysis | before | after |
|---|---|---|
unlinked_spawn | 85 MB | 1 KB |
deferred_startup_deadlock | 101 MB | 16 MB |
unsafe_task | 98 MB | 100 MB |
| all sixteen | 416 MB | 247 MB |
unlinked_spawn is the clearest case: its entire input set was
{instruction, spawn_call}, and it read all 343k instruction rows purely
to learn which function did the spawning — while spawn_call itself is
often empty. Its input set is now a single relation.
unsafe_task gets slightly worse, and that is the honest cost of stopping
here. It still reads instruction for start_child_result_checked, which
compares instruction indexes (bidx > sc_idx) to ask whether a branch
follows a call — a genuine use of position, not a decode of identity — so
it pays for the new column without shedding the old relation. Replacing
that rule with an extractor-computed fact would change findings, so it is
deliberately a separate change.
Stage 0's input set is now bif_call, closure_def, function_def,
local_call, remote_call — exactly the relations that describe what a
function calls. Its inputs are finally as stable as its output, which is
what stage0.dl's own header has claimed for the output alone since the
stratification landed.
Fixed (soundness)
clientlib/interprocedural.dlno longer depends on Souffle's conjunct order. Parameter forwarding was encoded incall_arg's value column as the string"arg:N"and decoded in Datalog withto_number(substr(marker, 4, strlen(marker) - 4)).to_numberis a partial functor — it aborts the entire program on non-numeric input — and thematch("arg:.*", marker)that kept literals away from it was a sibling conjunct, not a precondition. Souffle promises no conjunct order.The default schedule happened to be safe, which is why this never showed up. Under
souffle -mit is not: 7 of 16 analyses abort withwrong string provided by to_number("mic"), measured on the oban corpus.Forwardings are now their own relation,
call_arg_forward, with the forwarded position as a realnumbercolumn, and every partial functor is gone from the rule corpus. After the change all 16 analyses run clean under magic sets. The split is lossless — on oban,call_arg202,707 rows becamecall_arg160,100 +call_arg_forward42,607 — and findings are byte-identical (962 findings, 1259 beams).Beyond magic sets, this is a prerequisite for evaluating Souffle in-process: its generated code calls
abort()on functor errors, which inside the VM is not a failed analysis but a dead node.Schema version 9.
Argus.DlDeclarationsTestnow fails any rule that reaches for a partial string functor at all.
Changed
Extraction is now deterministic.
Argus.Pipeline.extract/2fanned out withordered: falseand merged by concatenation, so the reduce saw workers in completion order and row order varied run to run — measured at 8 distinct results from 8 extractions of the same 40 modules. Souffle has set semantics and never noticed, but every consumer that memoizes, hashes, or diffs facts did; planchette was sorting each relation itself to recover value equality. Ordering the stream costs nothing measurable (median 128ms vs 138ms over 90 modules — ordering is inside the noise, and slightly reduces variance) and makes reproducibility a property of the library rather than something each consumer re-derives.For input sets whose order can vary — a directory listing, a set difference, a parallel discovery pass — the new
Argus.Facts.canonicalize/1sorts every relation so two such extractions compare equal. Consumers that already hold facts partitioned per module or per relation should keep sorting those partitions instead; it is cheaper and the result is reusable.
Added
Souffle fact declarations are generated from
Argus.Schema.priv/dl/base.dland the newpriv/dl/layer2.dlare written bymix argus.gen.dland checked byte-for-byte by the suite. Declarations are positional, and Souffle cannot check them against what the emitter writes — twosymbolcolumns swapped parse fine and silently join the wrong values, producing findings that are wrong rather than absent. They were the one part of the schema with no mechanical link back to it: 95 hand-written.inputdeclarations of 50 distinct relations across 19 files, so most relations were declared several times over, each an independent chance to drift.83 of them are now deleted; rules files include the generated declarations instead. Declaring the whole schema costs nothing in the solve, and the suite now demonstrates rather than assumes it: every analysis's true input set — read out of Souffle's transformed RAM — is identical to what it was when each file declared only the handful it read. Findings over the oban corpus (962 findings, 1259 beams) are byte-identical.
Argus.DlDeclarationsTestalso pins those input sets, because they are the unit of incremental work: a consumer re-solves an analysis when any relation in its set changes, so an accidental widening is a silent latency regression. One assertion is deliberately a marker rather than a guard — exactly three analyses readinstruction, the largest and most volatile relation, and twelve of the fourteeninstruction(...)uses in the rule corpus exist only to recover a call's containing function from its ID.Argus.InstrId.mint/2,func_id/2,3andfunc_id_of/1— the wire format for instruction and function IDs now has exactly one definition, withparse/1andparse_func/1as its inverses. Nineteen sites previously built these strings by interpolation:Normalizefor Layer 1, seventeen scattered through eight Layer-2 extractors, andArgus.Lines, which had its own copy offormat/1. Instruction indices are raw per-function offsets carried by 49 of the 78 relations, so any future change to how instructions are named needs one definition to move, not nineteen.This also fixes a latent bug in
Emit: recovering a parent function ID split on the first#, which truncates compiler-generated names that contain one and yields a function ID that joins against the wrong function.func_id_of/1is right-anchored like the rest ofInstrId.Argus.Schema.Pin— ause-able compile-time assertion that argus's fact schema is one the consumer was written against. The workspace had three hand-rolled copies of this check (planchette, gloss, lowdown) with three different semantics, and the duplication had already cost a real breakage: the v8 bump updated gloss's copy and missed lowdown's, so lowdown silently stopped compiling against its own path dependency.use Argus.Schema.Pin, versions: 3..8, review: "Gloss.Entries and Gloss.Adapters"Raises
CompileErrorat theusesite naming the consumer, the pinned range, the version argus actually declares, and what to re-read. A non-contiguous pin renders as a list rather than a range, because the gap is the part a reader needs to notice. The per-version reasoning stays as comments in the consumer — whether a bump matters depends on which relations that consumer reads, and only the consumer knows that.
Fixed (robustness)
Argus.Pipeline.Normalizeno longer crashes on improper lists in BEAM literals.is_list/1is true for[head | tail]with a non-list tail, butEnum.map/2raises on it, so a single such literal took the entire extraction down with aFunctionClauseErrorreported far from its cause. Found by sweeping planchette over a corpus of real projects: poison ships one, and every module in the project failed as a result. Normalization now walks cons cells, so proper and improper lists are both handled and the improper tail is preserved rather than silently properised.{:alloc, _}hints got the same treatment —Keyword.get/3raises on an improper list too.
Changed (schema version 8 — positional columns split out)
Three relations carried positional data that no rule joined on but that renumbered whenever anything earlier in a function changed, so they dirtied every analysis reading them on any body edit.
function_defloses itsentrylabel to a newfunction_entryrelation. The column was a wildcard in all 55 rule uses; onlyArgus.Cfgneeds it, to root each function's control-flow graph.call_argloses its call-site instruction ID — a wildcard in every use. The rules ask which FUNCTION passes which argument, which is stable.supervisorloses itssiteto a newsupervisor_site. Analyses that only ask "is this a supervisor, with what strategy" no longer depend on a positional value; the two that anchor a finding at the tree definition joinsupervisor_siteexplicitly and accept the coupling.
The principle is the one that already keeps line_info out of a semantic
fact set: positional data is payload to resolve late, never a join key.
Measured on eusapia, a body edit that adds instructions without adding a
call now leaves one_for_one_coupling, supervision, and
sync_call_in_init untouched, where previously all six analyses
re-solved. An edit that genuinely changes the call graph still re-solves
all six, as it must.
Verified byte-identical over 555 beams — every analysis's full output plus per-relation row counts and content hashes.
Changed (stratified call graph)
The shared call graph is now derived once by priv/dl/stage0.dl and read
by analyses as facts, instead of being re-derived inside every solve.
clientlib/imports.dldeclarescall_edgeas.inputand includesbase.dldirectly rather thancfg.dl. Souffle prunes unused input relations but not unused derived ones, so pulling in the cfg_edge rules kept every analysis demandingbranch/jump/next/label_at/select_branchfacts it never read.analyses/ets.dlandanalyses/unlinked_spawn.dllikewise now includebase.dl.call_reachablestays a per-analysis derivation over the staged edges: the closure is quadratic in the worst case (1.6MB ofcall_edgeexpands to 24.7MB on a 555-beam corpus), so materializing it would trade a cheap fixpoint for an expensive write-then-read.- Dead
cfg_reachableremoved — nothing referenced it, and as the closure of a ~10M-row relation it was a standing hazard.
Analysis input sets drop from 18–21 relations to 2–10, and the
supervision family (one_for_one_coupling, supervision,
sync_call_in_init) no longer reads instruction at all — so a consumer
that memoizes per relation can tell an ordinary body edit cannot have
changed their verdict.
New API: Argus.Analysis.derive_stage0/2, stage0_rules_path/0, and
input_relations/1 (resolved from the transformed RAM, the form that
actually executes — the parsed AST over-approximates, and walking
.include by hand under-approximates). Argus.Analysis.run_rules/3
derives stage 0 when a facts directory lacks it, so batch callers and
hand-built fact directories need no change; extract_facts/3 stages it
up front. A stage-0 failure degrades every requested analysis rather than
collapsing the call, preserving the "Souffle trouble is visible, not
fatal" contract.
Verified byte-identical: every built-in analysis's full output plus per-relation row counts and content hashes, over 555 beams.
Fixed (performance)
clientlib/cfg.dlno longer forces.output cfg_edge. The directive made Souffle materialize and write the control-flow graph on every solve even though no analysis readscfg_edgeorcfg_reachable, and every consumer then parsed the CSV back in only to discard it. Over a 555-beam corpus that was ~10.3M rows / ~1.07GB written per solve; a singleunsafe_taskrun went from 4.03s to 0.56s (7.2×), with byte-identical output. Analyses and tests that genuinely want the relation now declare.output cfg_edgethemselves.
Fixed (precision — 15-project OTP corpus audit)
Ran every analysis against 15 OTP libraries (bandit, broadway, cachex,
commanded, db_connection, finch, gen_stage, horde, libcluster,
nimble_pool, oban, phoenix_pubsub, quantum, redix, swarm) and verified
each finding against source. Total findings fell from ~260 to 88 with no
true positive lost; the remaining findings are the verified real ones
(the two atom_safety errors, Oban's coupling, the genuine timeout
chains) plus intentional-pattern warnings. Five analyses had systematic
false-positive mechanisms:
gen_statem(108 → 0, all false). Three root causes.state_functionsmode treated every arity-3 function as a state; it is now an arity-3 function that is exported, not called locally, and returns a gen_statem action — which excludes private helpers, compiler closures, exported client wrappers (connect_to_node/3), and action-returning helpers a state calls (disconnect/3). The initial state was inferred topologically, letting a dead state that transitions out masquerade as the entry point; it is now read frominit/1's return (schema v6statem_initial).handle_event_functionmode harvested every atom inhandle_event/4(:DOWN,:badarg, module aliases) as a state; the structural rules are now scoped tostate_functionsmode.extract_transitionsalso models the remaining action forms (repeat_state,stop_and_reply, bare:keep_state_and_data), so every state now implies a transition — makingcoverage_statem_no_transitionsunfireable, and it is removed.timeout_chain(32 → 3). All 29 false positives came from onecallback_sync_depclause built onstateful_module_dep, which (via its module-level heuristic) counted reaching a pure function (Config.get, an ETS read) as calling that module's server, never tied the dependency to the handle_call, and pulledasync_castedges into a "synchronous" chain. The clause is removed; the genuine chains derive from thegenserver_sync_apirules.timeout_chain_riskgains a[:from, :to]dedup key so a cycle yields one finding, not one per depth.error_handling(25 → 8).swallowed_errornever checked that the handler discards the exception — the idiomcatch kind, reason -> {:error, …}was flagged; a liveness scan now clears a handler that reads the caught exception registers before overwriting them, andraw_raise(the OTP-21 compiled form of:erlang.raise/3) is recognized as a re-raise.trap_exit_without_handlerno longer fires on gen_statem modules (which receive{:EXIT, …}in state functions).exit_in_callbackno longer treats:erlang.exit/1(a let-it-crash self-exit) as an imperative kill, and is downgraded to:infosince aProcess.exit/2in a callback is usually a deliberate protocol.distributed(21 → 5).rpc_in_genserver_callbackreported RPCs reachable transitively through a guarded dispatcher (Cachex'sRouter.route, whose clauses the function-granularity call graph collapses) — all 13 corpus findings were false; only an RPC directly in a callback is now reported, and the sites stay covered byrpc_without_timeout(whose message no longer claims "default" for an explicit:infinity).supervision(1 → 0).wrong_start_orderfired oninit_reaches, which counts a child'sinit/1calling any function in a dependency's module — including a pure helper (Horde'sNodeListener.make_members/1). It now requires an actual process interaction (sync call / cast) to the dependency at init.
The harness (scripts/harness.exs, analyze_project.exs) was
modernized for this audit: it runs every analysis by default, and the
report gains an otp_findings section — severity-ranked findings with
source-line-resolved anchors — feeding a cross-project triage.json
index. Fixes along the way: ebin discovery now finds _build/shared
(projects with build_per_environment: false), and --resume rebuilds
the triage index from all results on disk rather than clobbering it.
Added
port_openrelation + Ports extractor (schema version 7). A newArgus.Extractors.Portsrecords external port creation sites —Port.open/:erlang.open_port(Elixir'sPort.opencompiles to the latter),System.cmd,System.shell,:os.cmd— asport_open(id, func, mechanism, target), with the spawned command/executable resolved when it's a literal. A port is owned by the opening process and dies with it, so consumers can attribute it to that process in the supervision tree, the same way ETS tables are.- Dataflow primitives for value provenance.
Argus.Extractor.Helpersgainsrecent_writer/3(the most recent instruction that wrote a register, raw — for inspecting provenance) andkeyword_value_register/4(the register holding a given key's value in a runtime-built keyword list — for tracing a computed option likename:back to its source).call_result_origin/3now also reports local (call) origins, not just remote ones, so a value produced by adefphelper can be traced into that helper. General-purpose backward-dataflow building blocks. - Via-tuple (
Registry) registration names. The supervision extractor recognizes children registered under{:via, Registry, _}tuples built by a*.Registry.via(name, role)helper (the Oban idiom), recording the static role as the child's registered name — on both the registration side ({DynamicSupervisor, name: Registry.via(conf.name, Foreman)}) and thestart_childside (start_child(Registry.via(conf.name, Foreman), _), resolved through one level of local helper). So a queue supervisor Oban starts into the "Foreman" via nests under the DynamicSupervisor that holds it, instead of appearing unanchored. The runtime registry name is dropped — anchoring matches on the role alone, a deliberate over-approximation (correct for a single library instance). supervisor_child_namerelation (schema version 4). A child spec's registered:nameoption — theMyApp.Poolin{DynamicSupervisor, name: MyApp.Pool}— recorded alongsidesupervisor_childby{sup, position}. Lets a consumer anchor adynamic_childparented by a registered name (the parent ofDynamicSupervisor.start_child(MyApp.Pool, _)is the name, not any module) to the child that registers it. Only atom names are recorded;{:via, _, _}/{:global, _}names are not, since name-basedstart_childtargets are always atoms.use DynamicSupervisormodules recognized as supervisors. The supervision extractor now emits asupervisorfact forDynamicSupervisorbehaviour modules (strategy read fromDynamicSupervisor.init/1,:one_for_oneotherwise — the only strategy the behaviour accepts). Astart_childcall whose supervisor argument can't be resolved to an atom now anchors to the enclosing module when that module is itself a supervisor (the idiomaticstart_x(sup, …)helper), instead of dropping to the"dynamic"sentinel.- In-memory embedding surface.
Argus.Pipeline.extract/2accepts raw beam data binaries alongside module atoms and.beampaths (recognized viaBeamSpy.BeamFile.beam_data?/1), andArgus.Pipeline.write_facts/2is public, so embedders that hold bytecode in memory and merge per-module fact maps themselves can produce a Souffle-ready facts directory without temp-file round trips. Together withArgus.Analysis.run_rules/3andArgus.Analysis.filter_to_outputs/2, this is the blessed surface for incremental consumers (the lowdown pattern: assertArgus.Schema.version/0at compile time and decode withArgus.Facts.decode/1). - Post-dominators and control dependence on
Argus.Cfg.Cfg.Functiongainsipdom(immediate post-dominators — the same Cooper–Harvey–Kennedy fixpoint over the reversed CFG from a virtual:exit; blocks that never reach the exit are absent),postdominates?/3, andcontrol_deps/1(Ferrante–Ottenstein–Warren block-level control dependence). In-process API only — no fact-schema change. Powers planchette's flowistry-style slicing. Argus.Lines— line tables built fromline_infofacts (from_facts/1,from_facts_dir/1) with best-effortresolve/2for instruction IDs (exact line), function IDs and MFAs (first line), andArgus.InstrIdstructs.mix argustext output andscripts/analyze_project.exsnow annotate every resolvable ID cell with its source line.
Changed (schema version 3) — per-line finding anchors
line_infocovers every instruction. Rows were emitted only at the{:line, ref}markers themselves, but anchors name call-site instruction IDs — so an exactby_instrlookup could never hit and every consumer silently degraded to function-first-line resolution. The emitter now tracks the line in effect and stamps it onto each instruction until the next marker; a no-location marker (reference 0, compiler-generated code) resets it to unknown, so generated code never inherits a source line it isn't from. Instruction-level anchors now resolve to their exact source line.supervisorgained asitecolumn — the instruction ID of theSupervisor.init/start_linkcall (or Erlang-style flags literal) that defines the tree,"dynamic"when not statically found — andstatem_stategained asitecolumn (the state function instate_functionsmode, the matching instruction inhandle_eventmode). Both are trailing additions; the schema version bump to 3 covers theline_infomeaning change and these shape changes.- Every module-anchored finding now carries a per-line anchor.
Analysis output relations gained witness columns threaded from their
rule bodies (
stateful_module_dep,init_reaches,module_reaches,sync_dep,sync_callerand friends now carry the witnessing function;ets/process_registry/distributedrelations carry the already-bound instruction), andfinding/2anchors upgraded fromat_module(line 1) to the witness. Onlycoverage's absence findings stay module-level — they have no code location by nature. - Supervision-family findings anchor at the tree definition.
one_for_one_coupling,wrong_start_order, andsuspect_transient_dependencynow anchor at the supervisor's strategy line — the defect is the composition and that is where the fix goes — with the coupling call demoted to a labelled related location in the child. - The coupling analyses no longer overlap.
unlinked_coupled_siblingswasone_for_one_coupling's rule plus a link-negation — a strict subset, so running both analyses reported every unlinked coupled pair twice (four stacked diagnostics on one strategy line). The link-exclusion now lives insideone_for_one_couplingitself (a linked pair's exit propagates and both restart together — the hazard is already mitigated, so this is also a precision win),unlinked_coupled_siblingsis removed, and the duplicatedwrong_start_orderkeeps a single home in thesupervisionanalysis. On eusapia:one_for_one_coupling: 2(unchanged),supervisionnow passes. - Witness rows deduplicate deterministically. A relation provable
through several call sites yields one Souffle row per witness; output
relations now declare a
:key(the fields that identify a logical finding) andArgus.Findings.dedupe_rows/2— public for in-process embedders — keeps the lexicographically least row per key, so finding counts never depend on witness multiplicity or row order.
Fixed (analysis rules; schema version 5)
Five shipped rules produced wrong output; all five lived in analyses
that had no analysis-level tests (only their input extractors were
tested), which is how they shipped. Each fix lands with a new
test/analyses/ file pinning positive and negative cases through real
Souffle — every analysis now has one.
ignored_start_resulthad never fired. Souffle'scontains(needle, haystack)takes the needle first; the rule asked "is the callee a substring of'start_link'" — false for every real callee, so the relation had never produced a row. Fixed the argument order and matched the delimited name (.start_link/,.start/) soMod.restart_link/1cannot substring-match. Audited every othercontains()inpriv/dl: none had a swapped needle.timeout_insufficientflagged the default-vs-default chain.t_ab <= t_bcmarked two chained calls both using theGenServer.calldefault (5000 vs 5000) as an:error— the universal configuration. The comparison is now strict (the equality trade-off is documented in the rule), and the via-API timeout rules no longer attribute an unrelated resolved sync call's timeout to the wrapper's module (callee must be the target module or"dynamic").distributedflagged any module'sinit/1. Its localinit_functionhad no behaviour gate, so a plain module's ordinaryinit/1was treated as supervisor startup; now uses the clientlib's behaviour-gated rule. Also::net_kernel.monitor_nodesin init was flagged (the exclusion tested"monitor"but the extractor emits"monitor_nodes"— a subscription flag, not a connection attempt), andglobal_register_riskfired on:global.register_name/3— the arity that supplies a conflict resolver, i.e. the fix for the race being reported.global_registergained a trailing arity column (schema version 5) and the rule flags only/2.gen_statemstructural rules gated on extraction confidence. A module whose transitions failed to extract (delegating state functions, unrecognized return shapes) had every state flagged both unreachable AND terminal — extraction-gap noise presented as findings. Both rules now require a concretely-resolved transition in the module (coverage_statem_no_transitionsstill reports the gap), andunreachable_staterequires no dynamic-target transition. The unusedstatem_timeoutinput and the moduledoc's advertised-but- unimplemented timeout/nondeterminism checks are gone.supervisionnow flags:temporarysiblings.suspect_transient_dependencymatched only:transient, missing the strictly-worse case (a temporary child is never restarted, not even after a crash). Renamed tosuspect_nonpermanent_dependencywith arestartcolumn covering both policies.- Known limitation now pinned by a test:
trap_exit_without_handlercannot fire foruse GenServermodules — the macro compiles a defaulthandle_info/2into every module, so the function-existence heuristic is vacuous for idiomatic Elixir code. A real fix needs clause-level pattern facts. Also dropped the unusedregistry_op/via_tuple/named_processinput declarations.
Fixed (schema version 2)
line_infonow carries real source lines. The emitter passed beam_disasm's{:line, ref}operand straight through, so thelinefield held a Line-chunk reference despite being documented as a source line number. The Disassemble stage now parses the module's Line chunk (BeamSpy.Source.parse_line_table/1) and the emitter resolves every marker at emit time. No-location markers (reference 0, on compiler-generated code) and modules without a parseable Line chunk emit no rows —line_infonever contains raw references. The schema version is bumped to 2 for this meaning change; the relation's shape is unchanged.
Fixed
- Same-module supervisor children no longer collapse. Child spec
dedup keyed on the module alone, so three
{DynamicSupervisor, name: …}children (or twoLivebook.Utils.SupervisionStepsteps) were recorded as one. Dedup now keys on{module, registered_name}, so children that register different names stay distinct; nameless same-module duplicates still collapse (nothing distinguishes them). debug_linemarkers resolve to source lines. OTP 28 debug builds (beam_debug_info) carry adebug_line/4on every executable line; the emitter now treats it as a line marker (same sticky semantics asline), soline_infocovers debug twins — including the pure-data lines the production build never marks.executable_linestays ignored.- Calls now carry def/use facts. No call form emitted
usefacts for its argument registers (x0..x(arity-1)), and label-form local calls missed theirdef x0— so data dependence broke at every call boundary and a pipeline (a chain of calls threading x0) produced no def→use edges at all. Every call form (call/call_ext/call_fun/call_fun2/applyand the tail variants) now emits argument uses, and returning forms define x0.Argus.Dataflowedges flow through call chains; lowdown's line-mapping goldens moved where Rule A can now pull argument-setup moves to their consuming call's line — the drift its goldens had documented as a known-coarse gap. - Supervision children survive runtime list construction. One
runtime element in a children list (the stock Phoenix
Applicationshape —{DNSCluster, query: Application.get_env(...)}) splits the list into cons cells, and the extractor missed every literal member riding input_listoperands (bare module heads, the literal tail) — zero children extracted,⚠ children not statically resolved. The extractor now recovers members from cons construction (membership over perfect ordering: elements interleaved with runtime construction can land out of position), and the runtime-tuple scan accepts anyElixir.-prefixed module atom instead of requiring the module to be loadable in the analyzing VM — the analyzed project's deps never are. - Concurrent VMs no longer share temp directories. Analysis work
dirs and Souffle output dirs were named with
System.unique_integer/1alone — a VM-local counter — so concurrentelixirsubprocesses (e.g.mix argus.autoresearch measure's per-project workers) drew the same names and silently clobbered each other's facts mid-run, making corpus measurements nondeterministic (the long-standing "47 vs 73 vs 146" variance; three of five projects could return byte-identical reports for the wrong codebase). Names now include:os.getpid(). Consecutive corpus measures are exactly reproducible. - The
:dynamicplaceholder atom can no longer leak into facts. Partial register resolution marks unknown structure components with the:dynamicatom; three paths let it escape into fact fields as the string":dynamic", which every"dynamic"filter in the rules fails to match.resolve_register/3now reports a top-level placeholder as unresolved, and the GenServername:option /{:local, name}/ via-tuple consumers guard their nested slots. On the corpus this removed two forgedcoverage_named_process_unreachablerows (phoenix_pubsub) and surfaced two honestgen_server_start_nameimprecision events in their place.
Changed
atom_safetyandwhereis_racerows anchor at the call site.atom_exhaustion_risk,unsafe_deserialization_finding,code_injection_risk(now(id, func, api)) andwhereis_race(now(id, func, name)) carry the offending instruction ID, and the exhaustion/injection rules key rows by site instead of by reaching export (one row per unsafe call, anchored where the fix goes, rather than one per exported entry). Consumers of these output relations must account for the new leadingidfield.
Added
Argus.Extractor.Helpers.call_result_origin/3— traces a register back to the remote call whose result it holds, following move chains with sound register lifetimes (y registers survive calls; non-x0 x registers stop at call boundaries). The ETS extractor uses it to map operations on table references back to their same-function:ets.new/2site, so create-and-seed patterns join theirets_newrows by name instead of falling back to"dynamic". (Both corpus tiers are unchanged by this — their remaining ref-based ops live in different functions than the creation, which needs cross-function dataflow.)Argus.run_analyses/2— structured findings API. Runs a selection of built-in analyses (:allby default, excluding thecoveragemeta-analysis) against one shared fact extraction and returns{:ok, %Argus.Findings{}}: every output-relation row becomes a finding map (%{analysis, severity, title, detail, module, mfa, instr, related}) with prose explaining why it matters and the most precise code anchor the row allows (instruction ID →Argus.InstrId, function ID →{m, f, a}, module string → module atom). Severities are assigned per relation by each analysis module's new optionalfinding/2callback and documented in its moduledoc. Degradation is explicit: missing Souffle →{:error, :souffle_not_found}; a single failing analysis → adegradednote while the others still run. Analyses share one facts directory (newArgus.Analysis.extract_facts/3+run_rules/3, whichArgus.Analysis.run/3, the mix task, and the report builder now all compose) and evaluate in parallel — all 15 analyses finish in ~250 ms for a single module.Argus.InstrId.parse_func/1— parses function ID strings ("Mod:func/arity", the instruction ID format without#idx) with the same right-anchored rules asparse/1.Argus.Dataflow— reaching definitions over Layer-1 facts:def_use_edges/1returns the def→use edge set (which instruction's register write feeds which read), computed block-locally (straight-line chains, gen/kill summaries, worklist fixpoint, one local resolution walk). The successor relation comes from the explicit control-transfer facts (next/jump/branch/select_branch); exception and bif-fail edges are deliberately not followed (the handler's VM-materializedx0–x2have nodeffacts, so following them would fabricate flows).type_testfacts for the structural pattern tests.is_nonempty_listandis_tagged_tuplenow emittype_testrows alongside the guard-style unary tests. Both type-test their first operand (thesrcfield);is_tagged_tuple's arity/tag operands are not recorded. The relation shape is unchanged — only its row coverage grows.Typed fact API for in-process consumers.
Argus.Pipeline.extract/2acceptsformat: :typed, decoding rows against the schema via the newArgus.Facts.decode/1— field-name-keyed maps with integers fornumber/labelfields andArgus.InstrIdstructs (right-anchored parse, safe for generated-fun-N-names) for instruction IDs. The Souffle.decl/.factssurface is byte-identical: the new semantic field kinds (:instr_id,:func_id,:label) collapse tosymbol/number.Argus.Schema.version/0— a fact-schema version (now 1) for consumers to assert against at compile time; bumped on any relation/field change.Argus.Cfg— basic-block control-flow graphs from Layer-1 facts: leader-algorithm blocks with typed edges (fallthrough/jump/branch-pass/ branch-fail/select-arm/select-fail/exception), a dominator tree (iterative Cooper–Harvey–Kennedy over reverse postorder), natural-loop headers, and per-function entry resolved fromfunction_def's entry label (instruction 0 is the func_info failure pad, not the entry). Region/dominance helpers onArgus.Cfg.Function.Receive-loop control-flow facts.
loop_recnow emits its empty-mailbox branch,loop_rec_end/waittheir loop-back jumps, andwait_timeoutits message-arrival branch — receive loops previously had no back edges in the fact base, so no analysis could see them as loops.mix argus.autoresearchMix task with 9 subcommands:init,measure,diff,rank,checks,accept,revert,status,note. Each wraps a public API function inArgus.Autoresearchso the logic is unit-testable.Corpus measurement (
Argus.Autoresearch.Measure) — parallel per-project subprocess fanout extracted fromscripts/harness.exs. One implementation, two entry points. Ebin discovery via runtime:code.get_path/0(replaces harness's compile-timePath.wildcard).Canonicalized snapshot (
Argus.Autoresearch.Snapshot) — reduces per-project coverage reports to a deterministic, diffable form. Strips nondeterministic fields (timestamp, duration), sorts all lists, caps sample_funcs at 10. JSON round-trip preserving.Structural diff (
Argus.Autoresearch.Diff) — per-category and per-shape-gap deltas with improvements/regressions views, net total, and new/removed category tracking. Schema-version-aware (refuses cross-version diffs).Priority ranker (
Argus.Autoresearch.Ranker) — scores bydelta_weight × severity × spread_bonus × stickiness_dampener. Shape-gaps get 3× severity. Dead-end exclusion from notes.md.suggested_extractormaps 25 category prefixes to source files.Checks barrier (
Argus.Autoresearch.Checks) — configurable command sequence (default: format + compile + test + dialyzer) plus canary correctness cross-check against a committed fixture.Session event log (
Argus.Autoresearch.Session) — append-only JSONL with event types: baseline_set, measure, rank, attempt_start, checks, remeasure, attempt_end, baseline_promoted, revert, note.Baseline store (
Argus.Autoresearch.Baseline) — committed.autoresearch/baseline/with snapshot.json, metadata.json, and canary_correctness.json. Promote/read/exists? API.Config (
Argus.Autoresearch.Config) —.autoresearch/config.exswith named corpus tiers (fast/medium/full), canary project, barrier commands, concurrency settings.Claude Code skill at
.claude/skills/argus-autoresearch/SKILL.md— 9-step iteration workflow with two confirmation gates, commit protocol (two commits per accepted iteration), dead-end protocol, and safety rules.Initial baseline for the fast-tier corpus (poolboy, phoenix_pubsub, plug, jason, bandit): 95 imprecision events across 12 categories, 19 shape-gap rows.
Changed
- beam_spy is now a workspace path dependency (was hex
~> 0.1.0): argus needs beam_spy's unreleased Line-chunk table fix forline_inforesolution, and lowdown already overrides the dep to the sibling. scripts/harness.exs— subprocess invocation delegated toArgus.Autoresearch.Measure.run_analysis_subprocess/4. The harness still owns compile orchestration and triage; only the subprocess primitive is shared.
Fixed
branchrelation fields told the wrong story. The emitter has always put the test's fail label in the field documented ason_true("label if condition holds"). Fields are nowfail/reserved(names only —.factsoutput is positional and unchanged), and the never-firing false-arm rule inclientlib/cfg.dlis gone.
Notes
Day-zero baseline counts (fast tier):
| Category | Count |
|---|---|
| ets_table_ref_op | 30 |
| ignored_result_unknown_api | 22 |
| genserver_callee | 19 |
| registry_op_key | 8 |
| gen_server_start_name | 5 |
| deferred_reply_from | 2 |
| ets_table_name_new | 2 |
| process_link_target | 2 |
| supervisor_child_module | 2 |
| delayed_target | 1 |
| exit_call_target | 1 |
| sync_call_timeout | 1 |
| Total imprecision | 95 |
Shape-gap rows: 19 across 4 relations (coverage_genserver_isolated: 11, coverage_ets_unused: 3, coverage_named_process_unreachable: 3, coverage_supervisor_no_children: 2).
0.4.0 — Unreleased
Coverage and precision instrumentation — Argus can now measure its own extractor pipeline. Every fallback to a dynamic placeholder is recorded as a fact, and passive Datalog rules derive "recognized shape but no detail extracted" findings from the existing fact base. This closes the feedback loop for iterative precision work: run coverage, change a resolver, re-run, see whether the imprecision count dropped.
Added
coverageanalysis — new built-in meta-analysis measuring the extractor pipeline itself. Exposes rawimprecision_eventevents (one row per extractor fallback site that fired) plus five shape-gap relations derived from the existing fact base:coverage_supervisor_no_children— supervisor recognized but no static or dynamic children recoveredcoverage_genserver_isolated— GenServer with zero observed sync/async traffic in the corpuscoverage_ets_unused— concretely-named table with no observed read or write operationscoverage_statem_no_transitions— gen_statem with states but no transitions extractedcoverage_named_process_unreachable— registered name with no traffic targeting it Not in the default correctness set — it's a developer-facing meta-analysis. Users opt in viamix argus coverage.
imprecisionLayer 2 fact relation — records extractor fallback events (category,func,relation,reason). Categories are namespaced (genserver_*,supervisor_*,ets_*,statem_*, etc.) and documented in CHANGELOG as a versioned vocabulary.track_dynamic/5,track_imprecision/5,enable_tracing/0,disable_tracing/0helpers — gated on a per-process flag so non-coverage runs incur only a single process-dict read per fallback site.Argus.Analysis.run/3flips the flag on automatically when the analysis is:coverage.- 28 tracking call sites across 8 extractors — every
resolve_*fallback site now either callstrack_dynamic(when a dynamic placeholder is emitted) ortrack_imprecisionwith reason:skipped/:missing/:unresolvable(when the fact is suppressed entirely). Coverage categories shipped:genserver_callee,process_link_target,delayed_target,delayed_message_pattern,deferred_reply_from,sync_call_timeout,dynamic_supervisor_parent,dynamic_supervisor_child,supervisor_child_module,supervisor_strategy,ets_table_name_new,ets_table_ref_op,ets_options_unresolved,process_register_name,registry_op_key,via_tuple_registry,via_tuple_key,whereis_target,gen_server_start_name,rpc_timeout,global_register_name,global_op_retries,exit_call_target,trap_exit_unresolved,ignored_result_unknown_api,statem_transition_target,statem_timeout_value,statem_callback_mode_unknown,unsafe_deserialization_safety,gen_event_handler_unresolved.
Notes
mix argus --list now shows 16 analyses. Existing 15 analyses are
unchanged — the imprecision side-channel is purely additive and gated
off by default, so mix argus <anything-but-coverage> incurs no cost
and produces empty imprecision.facts.
0.3.0 — Unreleased
Bytecode-analysis precision improvements and one new analysis built on top of them. Implements the punch list from the post-prune gap audit.
Added
deferred_startup_deadlockanalysis — narrowly-scoped detector for threehandle_continue/2patterns: mutual continue cycles between two GenServers, continue calls to a sibling started later under:one_for_one, and continue calls back into the parent supervisor while it's still mid-start_link. Plus a separatecontinue_crash_loop_riskfinding for defensively-wrapped variants (try/catch :exit) that catch the literal deadlock but create supervisor restart loops. Verified zero false positives against the ~100-project sample corpus, including Oban.gen_eventextractor — covers:gen_event.sync_notify/2,notify/2,call/3,4,add_handler/3,add_sup_handler/3. Reusessync_call/async_castso existing analyses (call_cycle,process_bottleneck) automatically catch gen_event patterns.- PartitionSupervisor child shape support —
{PartitionSupervisor, child_spec: SomeWorker}now extracts to the underlying worker module instead of treating PartitionSupervisor as the worker. - DynamicSupervisor.start_child extraction — adds a new
dynamic_child(sup, child_mod, caller_func)fact and extendschild_subtreesoone_for_one_couplingand friends see workers added at runtime (oban Midwife, Phoenix Channels, Plug.Upload). closure_defLayer 1 fact — everymake_fun3instruction now emits an edge from the parent function to the lifted closure body. Treated as a static call edge in the call graph, socall_reachablefollows execution into closures passed toEnum.map,:telemetry.span,Task.async,Phoenix.PubSubcallbacks, etc. Concrete fix:Oban.Peers.Global.handle_info → :global.set_lock, which lives inside a telemetry-span closure, is now reachable.- Function-entry barrier and
arg_position/3helper — backward resolution can now distinguish function parameters from "I don't know" without changingresolve_register/3's value contract. - Pattern-matched destructuring resolution —
{:ok, val} = call()resolves to{:call_field, "Mod:func/arity", 1}instead of:dynamic. Foundation for future analyses that correlate destructured values with their originating call sites. - Pure-BIF whitelist —
:erlang.element/2,tuple_size/1,length/1,byte_size/1,atom_to_binary/1,++/2,hd/1,tl/1,map_size/1. Resolves to literals when args are statically known, otherwise falls back to:dynamic. :viatuple resolution in OTP —GenServer.call({:via, Registry, {MyApp.Registry, key}}, _)now emits async_call_viafact and tags the legacysync_callwith"via:RegistryInstance"so existing analyses can group calls to the same via target.:globalretries-aware blocking classification — newglobal_opfact captures:global.set_lock,:global.trans,:global.del_lockwith the resolved retries argument. Datalog rules deriveglobal_blocking_oponly when retries is"infinity"or a positive integer (NOT"0"), so non-blocking try-locks like:global.set_lock(_, _, 0)are correctly not flagged.Process.send_after/:timer.send_after/:timer.apply_aftertracking — newdelayed_message(sender_func, target, message)fact captures implicithandle_infosources. Self() targets are recognized via the newlast_call_writer/3helper.GenServer.reply/2deferred_replyfact — infrastructure-only; no analysis consumes it yet but documents the deferred-reply pattern for future timeout-window analysis.named_processemission — ProcessRegistry now actually emits the schema-defined relation it previously only documented.- Layer 1 emit additions —
tuple_field_access(id, src, idx, dst)capturesget_tuple_elementindices,type_test(id, name, src, fail)captures unary type-test names that the genericbranchfact discarded, andunhandled_op(id, op)records every opcode that fell through the catch-all (so production runs surface what we're silently dropping).
Changed
scripts/analyze_project.exs—:deferred_startup_deadlockadded to@correctness_analysesdefault set.- OTP extractor
:viatuple handling — sync calls against{:via, _, _}targets now emit a structuredsync_call_viafact alongside the legacysync_call(callee tag becomes"via:RegistryInstance"). - Schema cleanup — dropped
process_monitor(extracted but never consumed). Verifiedsupervisor.strategyIS still consumed byone_for_one_couplingandsupervisionrules, so kept it.
Notes
mix argus --list now shows 15 analyses. The new
deferred_startup_deadlock is wired into both the mix task and the
default analyze_project.exs correctness set.
0.2.0 — Unreleased
A focused refactor: prune the analysis surface from 28 to 14 BEAM/OTP-specific detectors, clarify pipeline boundaries, and shrink the codebase by ~40% without losing the bug-finding power that matters.
Removed
- 12 generic dataflow primitives —
cfg,callgraph,callgraph_ctx,reachability,reaching_def,liveness,dominators,loops,tail_call,constant_propagation,function_summary,message_flow. These were classic compiler dataflow building blocks with no direct bug-finding value for end users. The shared rule library that backs the kept analyses (priv/dl/clientlib/) is preserved. - 2 non-BEAM analyses —
phoenix_security(framework-specific, better handled by web tooling) andresource_lifecycle(generic, high false- positive risk). Argus.Soufflebehaviour — was a 13-line indirection over a singleArgus.Souffle.CLIimplementation. Inlined intoArgus.Souffle.- DOT output format from
mix argus— only worked for the cut cfg/callgraph analyses. - Orphaned
priv/dl/clientlib/dataflow.dlandpriv/dl/clientlib/concurrency.dl. - Schema relations populated only by removed extractors.
Changed
- Pipeline namespace.
Argus.Extract,Argus.Normalize, andArgus.Emitterare nowArgus.Pipeline,Argus.Pipeline.Normalize, andArgus.Pipeline.Emit. A newArgus.Pipeline.Disassemblemodule owns module-path resolution and BEAM file loading. - Extractor helpers. New
scan_functions/4,scan_remote_calls/4,resolve_callee/1, andresolve_atom/3helpers inArgus.Extractor.Helpersremoved ~166 lines of duplicated per-instruction scan boilerplate across six extractors. mix argusdiscover_dep_modules now usesMix.Project.build_path/0Mix.Project.deps_apps/0instead of a hardcoded_build/{env}/lib/*/ebinglob. The previous version only worked when each dep had its own_build.
mix argusmodule discovery wrapsString.to_existing_atom/1, so stale.beamfiles for unloaded modules are silently skipped instead of crashing discovery.- README and docs refreshed to focus on the kept BEAM/OTP analyses, with grouped descriptions and a real Oban example.
Added
CHANGELOG.md(this file).
Notes for upstream users
This is a 0.x project with no published Hex releases. Anyone using
Argus.Extract, Argus.Normalize, or Argus.Emitter directly will need to
update to the Argus.Pipeline.* names. Anyone running cut analyses
(mix argus cfg, mix argus callgraph, etc.) will need to migrate to one
of the kept analyses or write a mix argus custom rules file against the
preserved priv/dl/clientlib/ rule library.
0.1.0
Initial research release with 28 analyses spanning generic dataflow primitives, BEAM/OTP bug detectors, and framework-specific checks.