2.7.1 - 2026-05-23
Changed
- Smell suppressions —
mix reach.check --smellsnow supports config-level path/module ignores plus Credo-style# reach:disable-next-lineand# reach:disable-for-this-filecomments for local suppressions. - Non-Elixir source safety — source-backed and file-backed smell checks now skip non-Elixir module files contributed by plugin frontends instead of parsing or inspecting them as Elixir source.
2.7.0 - 2026-05-23
Changed
- Bare rescue smell — added a generic smell check for
rescue _ ->andrescue error ->clauses that should narrow the rescued exception set. - LiveView mount Repo smell — added a Phoenix plugin smell for direct Repo work in
mount/3without aconnected?/1guard or async LiveView loading. - Internal rescue handling — replaced avoidable exception-driven control flow in Reach's own analyzers with explicit checks and narrower error paths.
- Corpus-tuned smell signal — reduced noisy
Enum.count/1, directEnum.map_join/3, publicdefdelegate, public forwarder, and one-off dual-key findings after reviewing Hex corpus output. - False-success error handling smell — added a generic check for
check/lint/validate-style functions that convert{:error, _}into success-like values such as[]or:ok. - ExUnit plugin — added an ExUnit plugin smell for
async: truetest modules that mutate global application, system, or persistent-term state. - ETS partial-key match smell — added a check for wildcard ETS matches over tuple keys that can return arbitrary stale/versioned rows.
- Macro fact foundation — added
Reach.MacroFactas the source-first representation for macro/DSL declarations, with plugin refinement for Phoenix, Ecto, and Ash declarations without generic framework allowlists. - Macro-aware dead-code filtering — dead-code checks now use high-confidence macro facts to avoid reporting framework DSL declarations such as Phoenix
attr,slot, router scopes, and routes as unused results. - Macro-aware behaviour candidates — behaviour-candidate checks now use macro facts that explain framework callback surfaces, including local
use MyAppWeb, :live_viewand:live_componentmacro aliases, suppressing LiveView-style callback groups without hardcoded framework allowlists in the generic smell. - Project-scoped effect classification — effect inference and effect-based summaries now use the project/plugin configuration instead of reusing unscoped local-function cache entries across plugin sets.
- Project query isolation — target/function query indexes no longer reuse process-local state across projects analyzed in the same VM.
- Plugin-aware dead-code graphing — dead-code checks now pass configured plugins into graph construction, not only macro fact collection.
- Atom-safe Phoenix route facts — Phoenix route macro facts now keep controller/live-view targets as source-level strings instead of creating module atoms from source aliases.
- Strict CLI option parsing — canonical command option parsing now raises on unknown switches instead of silently ignoring typos.
- Removed legacy Mix task shims — removed the old pre-2.x task modules entirely; use the five canonical commands (
reach.map,reach.inspect,reach.trace,reach.check,reach.otp). - Architecture check performance —
mix reach.check --archnow uses source-only project loading and avoids full system-dependence graph construction for architecture policy checks. - Changed-code check performance —
mix reach.check --changednow returns immediately for empty diffs instead of loading and analyzing the whole project first. - Plugin-owned framework policy — framework callback/effect-boundary semantics now live behind plugin callbacks instead of generic analysis modules.
- Target-project plugin inference — path scans now infer built-in plugins from nearby
mix.exsdependencies and source markers without executing the target project, with each plugin owning its own inference hints. - Plugin-aware AST entrypoint —
Reach.ast_to_graph/2now applies configured plugin AST lowering, matching source parsing behavior. - Macro fact scanner project scope —
scripts/macro_fact_scan.exsnow scans explicit files andlib/testdirectories at project scope so local macro aliases are resolved during real-project scans.
2.6.1
Changed
- Check text rendering — normalized
mix reach.checktext section spacing through shared check rendering helpers and architecture policy guards.
Fixed
- Map-shaped locations in text output — smell and OTP text renderers now format
%{file:, line:}and%{file:, start_line:}locations instead of crashing while JSON output continues to use structured data.
2.6.0
New
- LiveView/HEEx lowering — added an optional LiveView plugin that recognizes
~Hand.heextemplates, lowers HEEx control flow (if/case,:if,:for) into Reach's Elixir IR with template source labels/spans, and hides LiveView rendering helper edges from graph presentation. The plugin uses LiveView's parser when available and falls back to the stableTagEnginepath for current 1.1.x projects. - LiveView semantic edges — LiveView analysis now connects
JS.push/push_eventcalls tohandle_event/3, assign writes to HEEx assign reads, parser-lowered component attr values to component calls, and stream writes to@streams.*reads. Staticphx-*event attrs are modeled by the parser-backed HEEx lowerer when available. - Architecture layer ergonomics —
.reach.exsnow validates unknown layer references, supports allowlist-style dependency policy, dependency exceptions, optional layer coverage checks, and layer-cycle violations with concrete call-edge witnesses.
Changed
- Evidence provider namespace — moved clone analysis under
Reach.Evidence.CloneAnalysisand introducedReach.Evidenceas the namespace for reusable facts consumed by smells, checks, and refactoring candidates. - Jason plugin smell — added an evidence-backed smell check for hand-rolled JSON sanitizers, encoders, and simple
Jason.Encoderimplementations that should use Jason protocol support directly. - Standard library bypass smell — added conservative Path/URI checks for hand-written basename, extension, URL, and query-string splitting, plus higher-context
Enum.map→flatten, order-safe reduce/reverseEnum.flat_map, pairedMap.has_key?/Map.putupdate,Map.update!, and reduce-basedEnum.frequenciesheuristics. - Map contract evidence — added
Reach.Evidence.MapContractas a reusable evidence provider for maps that are created with a fixed shape, returned from local functions, and then read/updated as implicit contracts. - Map contract candidates —
mix reach.check --candidatesnow reports advisory struct, boundary, or typed-map contract candidates when repeated implicit map contracts appear in project source. - Poison plugin rename — split the Poison effect classifier into
Reach.Plugins.Poisonnow that Jason has its own plugin. - Evidence corpus scanner — added
scripts/evidence_corpus_scan.exsfor focused Jason, standard-library bypass, and map-contract evidence scans across repositories, backed by lightweightfamily/0andkinds/0evidence provider metadata. - Plugin evidence refinement — added a generic
refine_evidence/2plugin hook so dependency plugins can annotate reusable evidence without owning smell or candidate policy; Jason now classifies maps passed toJason.encode/1,2orJason.encode!/1,2as external payload contracts. - Corpus-tuned evidence — tightened evidence scanning after reviewing Hex corpus hits, including safe handling for dynamic aliases and avoiding
Enum.flat_map/2suggestions for reduce callbacks shaped likeacc ++ [expr]. - CLI JSON boundary — moved JSON output normalization into
Reach.CLI.JSONand removed the oldjsonifyhelper from formatting code. - Self architecture policy — tightened Reach's own
.reach.exswith layer coverage, removed-namespace guards, and extra call-boundary rules that keep CLI/Mix orchestration out of evidence, smell, frontend, plugin, and visualization modules. - Architecture hardening docs — added a configuration recipe showing how to combine layer coverage, dependency rules, call bans, removed-source guards, exceptions, and baselines for stricter architecture enforcement.
Fixed
- Root task help —
mix reach --helpnow prints usage information instead of generating the default HTML report. - Protocol and macro-heavy visualizations — Elixir frontend now attaches
defimpl/defprotocolfunctions and nested modules to their real module names, including multi-part nesteddefmodulenames, and treats quoted macro-generated definitions as data while preservingunquotereferences. This removes bogus(top-level)graph buckets and repeated garbage nodes in reports for macro-heavy projects. - Redundant computation false positives — stateful IR counter calls are no longer reported as duplicate pure computations during Reach's own strict smell checks.
2.5.0
New
- Trivial delegate smell check — added conservative detection for pass-through
defdelegatecalls and hand-written same-argument forwarding functions, including alias, Erlang module, and static generated delegate targets while allowing documented facades, behaviour adapters, semantic aliases, and ambiguous local calls. - Conversion idiom smell checks — added conservative checks for identity float arithmetic and one-shot
List.to_tuple/1+elem/2access.
Fixed
- Phoenix/Ecto dead-code false positives —
--dead-codeno longer reports compile-time DSL macros such asPhoenix.Component.attr/3,slot/3, Phoenix router macros, Ecto schema fields, or migration table/column declarations as unused results. - Corpus scan robustness —
scripts/smell_corpus_scan.exsnow reports per-repository exits as scan errors instead of aborting the whole corpus run, and selected-kind filtering handles source smell metadata. - Pipe-shaped macro heads — the Elixir frontend no longer crashes on unusual macro heads that use
|>as a user-defined operator pattern.
2.4.0
New
- Framework smell checks — added plugin-provided Phoenix, Ecto, and Oban smell checks for LiveView lifecycle mistakes, Ecto query pitfalls, unsafe SQL interpolation, money-like
:floatfields, and Oban argument shape issues. - Security/source smell checks — added checks for unsafe dynamic atom creation, unsafe
:erlang.binary_to_term/1, missing@external_resourcedeclarations, and conservative Ecto query pinning/cross-join issues. - Custom/plugin smell infrastructure — projects can provide custom smell modules via
.reach.exs, and framework plugins can register smell checks throughReach.Plugin.smell_checks/0. - Smell corpus tooling — added
scripts/smell_corpus_scan.exsfor repeatable scans across external repositories with plugin and kind filters. - Smell profiling tooling — added
scripts/profile_smells.exsfor per-check, per-pattern, and per-query profiling against the current project or an external repo.
Changed
- Source smell DSL rename — source-level smell checks now use
Reach.Smell.Check.Sourceinstead ofReach.Smell.Check.Pattern. The unifiedsmell/4DSL also supports AST callback rules withmode: :astfor hot source-shape checks that need custom matching. - Pattern smell performance — source smell checks infer source prefilters from ExAST selectors, share prefilter normalization through
Reach.Smell.PatternConfig, and route combined pattern/AST callback rules throughReach.Smell.SourceRunner. - Smell performance — optimized pattern scanning, source AST caching, CLI command tests, architecture policy tests, and the
IdiomMismatchsmell check to reduce repeated full-project/file traversals. - Pattern smell consolidation — consolidated hot ExAST selectors for boolean
caseand booleanMap.put/3tracking; the booleancasesmell now uses the unified source DSL's AST callback mode.
Fixed
- Plugin smell discovery — plugin smell modules are no longer accidentally auto-discovered as generic built-ins; plugin checks run only through plugin registration.
- Real-world false positives — narrowed noisy Phoenix raw HTML, LiveView
assign_new, and Oban argument checks based on scans of open-source Elixir projects. - Pattern prefilter safety — inferred prefilters ignore ExAST predicate internals so DSL-style selectors such as Ecto
field(name, :float)are not skipped incorrectly.
2.3.5
New
- Strict smell gates —
mix reach.check --smells --strictnow fails when non-baseline smell findings are present. Projects can also enable this withsmells: [strict: true]in.reach.exs. - Check baselines —
mix reach.checksupports--baseline PATH,--write-baseline PATH, andchecks: [baseline: PATH]to suppress known architecture/smell findings while failing on new ones.
Changed
- Combined checks —
mix reach.check --arch --smellsnow runs all requested check modes and shares one project analysis where possible. - Dogfooding — Reach now runs strict smell checking against itself in CI with tuned smell thresholds and an explicit baseline file.
Fixed
- Optional JavaScript support — compiling Reach without QuickBEAM no longer emits warnings about the unavailable JavaScript frontend.
- Self-smells — fixed actionable smell findings in Reach's own code and prevented pattern smell declarations from flagging themselves.
2.3.4
Fixed
- Docs publishing —
mix docsandmix hex.publish docsnow run in the:docsMix environment, avoiding dev-only dependency self-conflicts during Hex docs publishing.
2.3.3
New
- Semantic idiom smells — detects double map lookups, sentinel
Map.get/3defaults, length-based list indexing, invalid integerKeywordkeys, and missingLoggerimports. - More collection idioms — detects sort-then-reverse, sort-then-index, and
Enum.take_while/2followed by counting.
Changed
- Dependencies — refreshed Hex dependencies, including Volt 0.10.x.
Fixed
- Smell false positives — tightened keyword-default and Logger detection based on Hex package scans.
- Nested Mix tests — run the ProgramFacts stress script under
MIX_ENV=testto avoid dev-only dependency self-conflicts.
2.3.2
New
- Case-on-boolean — flags
case expr do true -> ...; false -> ... endwhen the subject is a comparison or boolean operator. Uses ExAST capture guards to avoid false positives on sentinel pattern matches. - Case to match? — flags
case _ do pat -> true; _ -> false end; suggestsmatch?/2. - Needless bool — flags
if cond, do: true, else: falseand the inverse. - Manual max/min — flags
if a > b, do: a, else: busing ExAST repeated-variable matching; suggestsKernel.max/2orKernel.min/2. @doc falseondefp— flags redundant@doc falsebefore private functions.- Sort then negative take — flags
Enum.sort |> Enum.take(-n); suggestsEnum.sort(:desc) |> Enum.take(n). - Cond two-clause — flags
cond do ... true -> ... endwith exactly two clauses; suggestsif/else. - Unless/else — flags
unless ... else ...; suggestsifwith positive case first. - Redundant assignment — flags
result = expr; resultwhere the binding is immediately returned. - Redundant nil default — flags
Keyword.get(_, _, nil)andMap.get(_, _, nil). - Split then head — flags
String.split(s, sep) |> hd/List.first; suggestsparts: 2. - Filter then first — flags
Enum.filter |> List.first/hd; suggestsEnum.find/2. - Map.new / MapSet.new — flags
Enum.map |> Enum.into(%{}),Enum.into(_, %{}),Enum.into(_, MapSet.new()),Enum.map |> Enum.concat.
Fixed
++in reduce false positives — the check now verifies that an operand of++actually references the reduce accumulator variable.- Dogfooding — fixed all actionable smell findings in Reach's own code across 15 files.
- CI —
mix cinow runsreach.check --arch --smells. - Credo overlap documented in README.
Changed
- ex_ast bumped to
~> 0.11.2.
2.3.1
New
- Case-on-boolean detection — flags
case expr do true -> ...; false -> ... endwhen the subject is a comparison or boolean operator. Uses ExAST capture guards to avoid false positives on sentinel pattern matches likecase func() do false -> ... end. - Cond two-clause detection — flags
cond do ... true -> ... endwith exactly two clauses; suggestsif/else. - Unless/else detection — flags
unless ... else ...; suggestsifwith the positive case first. - Redundant assignment — flags
result = expr; resultwhere the binding is immediately returned. - Manual max/min — flags
if a > b, do: a, else: busing ExAST repeated-variable matching; suggestsKernel.max/2orKernel.min/2. @doc falseondefp— flags redundant@doc falsebefore private functions (uses ExAST 0.11.2 wildcard function name matching).- Sort then negative take — flags
Enum.sort |> Enum.take(-n); suggestsEnum.sort(:desc) |> Enum.take(n).
Fixed
++in reduce false positives — the check now verifies that an operand of++actually references the reduce accumulator variable. Previously any++inside a reduce callback was flagged, including one-shot concats of two derived lists. Eliminates 17 false positives across the top 200 Hex packages.- Dogfooding — ran
reach.check --smellsagainst Reach's own codebase and fixed all actionable findings across 15 files. - CI —
mix cinow runsreach.check --arch --smells(was--archonly).
Changed
- ex_ast bumped to
~> 0.11.2for wildcard function name matching indef/defppatterns.
2.3.0
New
- Repeated traversal detection — flags the same variable traversed by 2+ different
Enumfunctions (e.g.Enum.max(list)+Enum.min(list)+Enum.count(list)); suggests combining into a singleEnum.reduce/3. - Nested enum detection — flags
Enum.member?/2nested inside anotherEnumtraversal of the same variable (O(n²)); suggests precomputing aMapSet. - Multiple Enum.at detection — flags 3+
Enum.at/2calls on the same variable with literal indices; suggests pattern matching. Append in recursion — flags
++ [item]in recursive tail calls (O(n²)); suggests prepend with[item | acc]andEnum.reverse/1in the base case.- Piped Regex.replace — flags
text |> Regex.replace(~r/.../, "")where the pipe injects the string as the regex argument; suggestsString.replace/3. Uses the new ExASTpiped()predicate to avoid false positives on directRegex.replace/3calls. - More Map.keys/values patterns —
Map.keys |> Enum.join,Map.keys |> Enum.uniq(redundant),Map.keys/values |> Enum.count/length(usemap_size/1),Map.keys |> Enum.member?(useMap.has_key?/2),Map.values |> Enum.sum/max/min/join. - More pipeline waste —
List.foldr/3,Enum.min_by/max_by/dedup_bywith identity function,Enum.filter |> Enum.filter,Enum.map |> Enum.flat_map/List.flatten,Enum.sort/2 |> Enum.reverse,Enum.with_index |> Enum.reduce, redundantEnum.map_joinempty separator. - More collection idioms —
Integer.to_string |> String.graphemes(useInteger.digits),length(String.split) - 1(Python count idiom),Enum.at(list, -1)(useList.last/1). - Parser warning suppression —
Code.string_to_quotedcalls now passemit_warnings: falseso reparsing dependency source files no longer emits tokenizer/parser warnings.
Changed
- ex_ast bumped to
~> 0.11.1for thepiped()selector predicate.
Fixed
- Smell false positives — IR-based checks (repeated traversal, multiple Enum.at) now scope per-clause to avoid false positives from multi-clause functions. Corpus-tested against 200 top Hex packages: 0 crashes, 0 false positives.
2.2.0
New
- Length comparisons — flags
length(list) == 0,0 == length(list), andlength(list) > 0; suggests list pattern matching,== [], or!= []. - Identity
Enum.uniq_by/2— flagsEnum.uniq_by(collection, fn x -> x end); suggestsEnum.uniq/1. - Identity
Enum.sort_by/2— flagsEnum.sort_by(collection, fn x -> x end); suggestsEnum.sort/1. length/1in guards — flags small literal comparisons in guards; suggests list pattern matching.
Fixed
- Regression coverage for bare literal
withclauses — keeps valid clauses such astrueinwithblocks from regressing. - CI — refactored the length-in-guard check to satisfy strict Credo nesting rules.
2.1.0
New
- Enum.at/2 inside loop — flags O(n) indexed access inside loops (O(n²) total).
- List.delete_at/2 inside loop — same O(n²) concern.
- Enum.count/1 without predicate — suggests
length/1(avoids protocol dispatch). - Map.put with variable key and boolean value — suggests MapSet for membership tracking (excludes atom/string keys to avoid struct field false positives).
- Map.values → Enum.all?/any?/find/filter/map — iterate the map directly as
{key, value}pairs. - Enum.map → Enum.max/min/sum — allocates intermediate list; use
*_byor reduce. - List.foldl/3 — non-idiomatic; use
Enum.reduce/3. - String.graphemes → Enum.reverse → Enum.join — use
String.reverse/1. - Redundant negated guard — flags
when x != yimmediately afterwhen x == yon the same variables. - Destructure then reconstruct — flags
[a, b, c]pattern where the body rebuilds the same list.
Fixed
- Frontend crash on
import Mod, only: :macros— atom values (:macros,:functions) are now handled correctly instead of crashing withEnumerable not implemented for Atom. - Frontend crash on macro-generated AST — bare atoms in
withclause lists, non-listelseclauses, and non-list handler clauses no longer crash the parser. - CI compatibility — full
mix ciruns on Elixir 1.19 only; 1.18 runs compile + tests (formatter output differs between versions).
2.0.1
Fixed
- ex_ast dependency —
ex_astis now a regular dependency instead ofonly: [:dev, :test]. Pattern smell checks import ExAST at compile time, so the previous declaration made Reach uninstallable from Hex. (Fixes #14)
Improved
- Smell false positive reduction — narrowed loop antipatterns to accumulator loops and recursive operands, excluded compile-time constructs and formatting functions from redundant computation, removed debatable patterns (chained
String.replace,Enum.takewith negative count, sequentialEnum.filter). Validated on 19 Hex packages: 63% fewer findings, all remaining verified as true positives. - Module documentation — all public modules now have
@moduledocdescriptions.
2.0.0
Breaking changes
- Canonical CLI surface - Reach now centers on the new dotted command model:
mix reach.mapmix reach.inspect TARGETmix reach.tracemix reach.checkmix reach.otp
- Legacy tasks removed - old task names now fail fast with exact migration instructions instead of running analysis:
mix reach.modules→mix reach.map --modulesmix reach.coupling→mix reach.map --couplingmix reach.hotspots→mix reach.map --hotspotsmix reach.depth→mix reach.map --depthmix reach.effects→mix reach.map --effectsmix reach.boundaries→mix reach.map --boundariesmix reach.xref→mix reach.map --datamix reach.deps TARGET→mix reach.inspect TARGET --depsmix reach.impact TARGET→mix reach.inspect TARGET --impactmix reach.slice TARGET→mix reach.trace TARGETmix reach.flow ...→mix reach.trace ...mix reach.dead_code→mix reach.check --dead-codemix reach.smell→mix reach.check --smellsmix reach.graph TARGET→mix reach.inspect TARGET --graphmix reach.concurrency→mix reach.otp --concurrency
- JSON envelopes changed - canonical commands now expose canonical
commandfields. Some delegated analysis payloads also includetoolto identify the internal analysis implementation. - Optional boxart dependency bumped - graph rendering now requires
{:boxart, "~> 0.3.3"}to pick up Unicode-safe syntax highlighting.
New
mix reach.map- project bird's-eye view with modules, hotspots, coupling/cycles, effects, boundaries, control depth, and data-flow summaries.mix reach.inspect TARGET- consolidated target view for context, dependencies, impact, data, graph rendering, slicing, advisory refactoring candidates, and--whyrelationship explanations.mix reach.trace- canonical entrypoint for taint flow, variable tracing, backward slicing, forward slicing, and slice graphs.mix reach.check- structural checks for architecture policy, changed-code risk, dead code, smells, and advisory candidates..reach.exsarchitecture policy with:layersdeps[:forbidden]source[:forbidden_modules]source[:forbidden_files]calls[:forbidden]effects[:allowed]boundaries[:public]boundaries[:internal]boundaries[:internal_callers]risk[:changed]candidates[:thresholds]candidates[:limits]smells[:fixed_shape_map]tests[:hints]
- Architecture violations for forbidden dependencies, forbidden modules/files, exact layer cycle components, effect policy, public API boundaries, internal boundaries, and config errors.
- Changed-risk reports with changed files, changed functions, aggregate risk, risk reasons, caller impact counts, public API touches, and suggested tests.
- Graph-backed advisory refactoring candidates:
introduce_boundaryisolate_effectsextract_pure_regionbreak_cycle
- Candidate metadata - candidates include
confidence,actionability,proof, and cyclerepresentative_callsso agents do not treat graph facts as automatic edits. - Umbrella source scanning - Mix project analysis now includes
apps/*/lib/**/*.exandapps/*/src/**/*.erl. - Project dogfood policy - Reach now ships a root
.reach.exsand runsmix reach.check --archin CI.
Improved
- Canonical implementation structure - legacy Mix task modules are removed shims; reusable logic moved into
Reach.CLI.Analyses.*modules used by canonical commands. - Canonical JSON consistency - canonical commands keep canonical
commandvalues even when using shared internal analyses. - Graph rendering reliability - Reach now relies on boxart
v0.3.3for Unicode-safe syntax highlighting and no longer sanitizes source snippets locally. - Text output polish - default text output is capped and formatted for readability on large projects, with
--limit Nand--allexpansion controls forreach.traceandreach.inspect --context. - Large graph UX -
reach.inspect FILE:LINE --graphuses a targeted single-file load and summarizes very large CFGs instead of flooding the terminal with huge Boxart output. - Taint tracing performance -
reach.trace --from ... --to ...computes reachable sinks per source instead of recomputing reachability for every source/sink pair. The Plausible validation case dropped from ~130s to ~3s. - ProgramFacts integration - Reach now uses generated Elixir projects from
program_facts ~> 0.2.0in test/dev validation for call paths, layouts, data flow, branches, richer syntax fixtures, effects, architecture policies, and advisory candidates. - Compiler diagnostics - BEAM frontend compilation now passes
return_diagnostics: trueand restores compiler debug-info options safely. - Effect policy precision -
:module_defand:function_defclassify as pure, so pure-only effect policies do not need to whitelist:unknownjust for wrapper nodes. - Public API policy precision - public API checks now use the configured public API namespace instead of only the first top-level module segment.
- Candidate precision - expected callback/effect-boundary shapes are suppressed for effect-isolation candidates,
:unknownand:exceptionno longer inflate mixed-effect candidates, and cycles prefer minimal representative evidence. - Changed deletion handling - deletion-only hunks are no longer attributed to a synthetic current-file line.
- Inspect data returns -
reach.inspect --datanow summarizes clause final expressions rather than direct clause nodes. - Loose map contract detection -
reach.check --smellsnow flags same-variable atom/string key fallback patterns such asmetadata["id"] || metadata[:id], a common sign that a map should be normalized once or replaced with a struct/explicit contract. Smell checks are now structured as individual checks behind a small behaviour. - Repeated map shape detection -
reach.check --smellsflags repeated atom-key map literals with the same shape as possible struct/contract candidates. - Clone-backed structural consistency - optional clone analysis evidence now enriches smell checks so Reach can flag return-contract drift, side-effect ordering drift, map-contract drift, validation drift, and higher-confidence behaviour candidates across similar code.
- Plugin-owned framework semantics - framework-specific trace presets, behaviour labels, and visualization edge filtering now live behind plugin callbacks instead of generic trace/map/visualization modules.
- Changed clone-family warnings -
mix reach.check --changednow reports similar cloned sibling functions so partial fixes are easier to spot and test. - Stronger ProgramFacts fuzzing - oracle validation now covers all generated policies at boundary sizes, metamorphic transform sequences that preserve declared facts, feedback-directed samples across canonical and target-based JSON commands, visualization block invariants, clone-backed smell checks, and a replayable stress script with saved failure corpora.
- Behaviour candidate detection -
reach.check --smellsflags groups of modules exposing the same public callback set as possible behaviour extraction candidates. - Compile-time vs runtime config detection -
reach.check --smellsflagsApplication.get_env/fetch_envcaptured in module attributes andApplication.compile_envused inside runtime functions. - ExAST-backed pattern smell DSL —
use Reach.Smell.PatternCheckwithsmell ~p[...]macro for declarative source-pattern detection. Pipes, operators, function calls, and module attributes all work with the~psigil. Guarded patterns usefrom(~p[...]) |> where(...). Pattern checks share a zipper cache across modules. - Collection and idiom smells —
reach.check --smellsnow detectsEnum.reverse |> hd,Enum.reverse ++ tail,inspect |> String.starts_with?, chainedString.replace,Map.keys |> Enum.map,List.to_tuple |> elem, redundantEnum.join(""), negativeEnum.take,String.graphemes |> length,String.length == 1,Integer.to_string |> String.to_charlist, and anonymous fn applied with.()in pipes. - Pipeline waste smells — detects
Enum.reverse |> Enum.reverse,Enum.filter |> Enum.count,Enum.map |> Enum.count,Enum.filter |> Enum.filter,Enum.sort |> Enum.take/reverse/at,Enum.drop |> Enum.take,Enum.take_while |> count/length, andEnum.map |> Enum.join. - Loop antipattern smells —
++inside loop (O(n²)),<>inside loop (O(n²)), manual min/max/sum reduce, and manual frequency counting withMap.update. - Idiom mismatch smells — guard equality where pattern match suffices,
Map.updatethenMap.get/fetchon same variable.
Documentation
- Added the configuration guide for
.reach.exspolicy configuration. - Added
examples/reach.exsas a starting architecture policy.
Validation
- Full CI passes: format, JS checks, Credo strict, ExDNA, architecture policy, Dialyzer, and ExUnit.
- Canonical command validation passed across 20 real codebases and every canonical submode, including graph modes and removed-command behavior.
1.8.0
New
gen_statem support -
mix reach.otpdetects and analyzes gen_statem state machines with both callback modes::state_functions- states extracted from public arity-3 functions (e.g.def connected(:cast, ..., data)) with return value validation:handle_event_function- states extracted fromhandle_event/4clause patterns, with module attribute resolution (@state :active)- Extracts initial state(s), state transition graph, and event types per state (cast, call, info, internal, timeout)
- Tested against Redix, Postgrex, and Livebook
Dead GenServer reply detection - finds
GenServer.callsites where the reply value is discarded. These calls could useGenServer.castinstead, or the handler could return a cheaper reply. Deduplicates findings from multi-clause function expansion.Cross-process coupling analysis - detects hidden data dependencies across process boundaries:
- Builds per-module effect summaries (ETS tables read/written, process dictionary keys, send targets)
- At
GenServer.call/castsites, flags when caller and callee share ETS tables or process dictionary keys - Reports conflict type:
callee_writesorcallee_reads_caller_write
Supervision tree extraction -
mix reach.otpfindsSupervisor.start_link(children, opts)calls, resolves children variable references to their list definitions, and extracts child module names from__aliases__AST nodes.
Improved
Smell detection false positives fixed - pattern cons operator (
|), string interpolationto_string, and unrelatedEnum.map/List.firstpairs no longer produce false findings. Eager pattern detection now requires actual data flow connection, not just line proximity. Same-line pipes sort by column for correct pairing.OTP analysis performance - precompute shared
all_nodesacross sub-analyses, replace O(n2)find_enclosing_modulewith O(1) index lookup. ~1000× speedup on the OTP-specific analysis for large codebases.Refactored OTP analysis into submodules - GenServer, GenStatem, Coupling, DeadReply, CrossProcess under
Reach.OTP.*.Self-healing - ran Reach on itself, fixed 9 redundant computations and 1 dead code finding in its own source.
1.7.0
New
JavaScript frontend - parse JS/TS source files into Reach IR via QuickBEAM bytecode disassembly. Handles function definitions, variables, closures, binary/unary operators, method calls, object literals, control flow, and async/await. TypeScript is stripped via OXC, ES module syntax (
export/import) is removed via OXC AST patching. Only available when:quickbeamis installed ({:quickbeam, "~> 0.10", optional: true}).QuickBEAM plugin - cross-language analysis for Elixir + JavaScript projects using QuickBEAM. Detects
QuickBEAM.eval(rt, "js source")calls with string literals, parses the embedded JS, and injects function nodes into the graph. Creates three edge types::js_eval- Elixir eval call → JS function definitions{:js_call, name}-QuickBEAM.call(rt, "name")→ JS named function{:beam_call, name}- JSBeam.call("handler")→ Elixir handler fn registered viaQuickBEAM.start(handlers: %{...})
Also classifies effects for QuickBEAM API (
eval/call→:io,compile→:read,set_global→:write), OXC (parse/postwalk→:pure,transform/bundle→:io), and Vize (→:io). Auto-detected when:quickbeamis loaded.Plugin API:
analyze_embedded/2- new optional callback that returns{[Node.t()], [edge_spec()]}, allowing plugins to inject IR nodes from embedded code (JS strings, SQL queries, etc.) and connect them to the host graph with cross-language edges.
Improved
Dead code - near-zero false positives - verified across Phoenix, Ecto, Oban, QuickBEAM, and Elixir stdlib (395 files). Remaining findings are all true positives.
- Compiler directives (
import,alias,use,@spec,@type,defstruct,\\,<<>>,when) no longer flagged - Type annotations inside
@spec/@typebodies no longer flagged - Variables captured by closures (e.g. in
Enum.reducecallbacks) tracked correctly via structural composite propagation through:fnand:guard withclause value expressions alive (both<-patterns and bare assignments likeconn = put_in(...)insidewithblocks)receiveafter-timeout expressions alivecase/condsubject expressions alive without making branch bodies alive (preserves dead code detection inside branches)- Struct pattern variable bindings (
%module{} = expr) tracked unquote/unquote_splicingvariables insidequoteblocks treated as references, not pattern definitions{:ok, _}tuple return types in specs no longer infer:pure- Inferred-type purity requires concrete data types, rejects bare
%{dynamic: :term}from NIF modules - Modules without typespecs no longer fall back to inferred-type purity
- Compiler directives (
PDG containment edges - added
:case,:fn,:receive,:try, and:guardto@value_typesinDataDependence, sobackward_slicecan traverse into control-flow constructs via graph edges instead of heuristic tree walks.File I/O effect classification -
File.read/stat/exists?→:read,File.write/cp/rm/mkdir→:write(previously all:io). Erlang:filemodule similarly split.Smell detection -
Enum.map(rows, &List.first/1)no longer flagged as the "Enum.map → List.first" anti-pattern. The detector now checks whetherList.firstis a mapper callback (descendant ofEnum.map) vs a pipeline step. Field accesses and compiler directives excluded from redundant computation detection.OTP analysis - catch-all handler clauses (
_msg/ bare variable) now respected in unmatched-message detection.
Fixed
withbody translation -split_with_clausesreturned opts as[[do: body]](list wrapping a keyword list), causingKeyword.getto returnnil. The entirewith do...endbody was silently lost in allwithblocks. Fixed by flattening opts. Pre-existing bug.withbare expressions - bare assignments insidewithblocks (e.g.conn = put_in(...)) now preserved aswith_clausenodes instead of being dropped bysplit_with_clauses.CodeandModulein@impure_modules-Code.ensure_loaded,Module.createetc. no longer inferred as pure.:lists.foreachin@effectful_in_pure_modules- Erlang's:lists.foreach/2classified as:io(likeEnum.each/2).:caseand:fnclassified as:pure- these are control flow constructs, not side effects. Previously classified as:unknown.
1.6.0
Improved
Unified target format -
reach.slice,reach.impact,reach.deps, andreach.graphall accept bothModule.function/arityandfile:lineformats. Previouslyreach.sliceonly acceptedfile:line, andreach.impact/reach.depsonly acceptedModule.function/arity.100-500x faster function resolution - indexed lookups replace linear scans of all IR nodes. On a 4k-function codebase: 10ms/call → 11-83μs/call.
Default argument awareness -
find_functionmatches functions called with fewer arguments than their definition (e.g.foo/1resolves todef foo(a, b \\ nil)).
Fixed
Function resolution - correctly resolve functions when module name casing differs from the source (e.g. QuickBEAM.Runtime vs Quickbeam.Runtime). Also handles projects where IR nodes store modules as nil by falling back to file path matching.
False positive elimination - module-qualified lookups no longer fall back to unrelated functions when the target module isn't found. Verified zero false positives across 14 real codebases (22k+ functions).
Internal
- Added
ex_dnatomix ciand eliminated all 9 pre-existing code clones. - Suppressed optional
boxartundefined-module warnings via@compile {:no_warn_undefined, ...}.
1.5.1
New
- Ash Framework plugin - effect classification and graph edges for the
Ash ecosystem. Covers Ash core CRUD (
Ash.create/read/update/destroyand bulk variants),Ash.Changeset,Ash.Query,Ash.ActionInput,AshPhoenix.Form,Ash.Notifier, resource DSL macros, and AshStateMachine DSL. Adds changeset-to-CRUD, query-to-read, form-to-submit, ActionInput-to-run_action flow edges, cross-module dispatch edges forchange/validate/preparecallback modules, and code_interfacedefine-to-action resolution. Auto-detected when the target project depends onash.
Fixed
- Compilation without boxart -
reachnow compiles and runs correctly withoutboxartinstalled. Struct literals (%State{},%PieChart{}) that expanded at compile time have been replaced with runtimestruct!/2calls. Graph commands raise a clear error when invoked without boxart (closes #9).
1.5.0
New
- 7 analysis commands for codebase-level insights:
mix reach.coupling- module-level coupling metrics (afferent/efferent coupling, Martin's instability metric, circular dependency detection).--graphrenders the module dependency graph via boxart.--orphansshows unreferenced modules.mix reach.hotspots- functions ranked by complexity × caller count, with clause breakdown for multi-clause dispatchers.mix reach.depth- functions ranked by dominator tree depth (control flow nesting).--graphrenders the CFG of the deepest function.mix reach.effects- effect classification distribution across the codebase and top unclassified calls.--graphrenders a pie chart.mix reach.xref- cross-function data flow via the system dependence graph (parameter, return, state, and call edges between functions).mix reach.boundaries- functions with multiple distinct side effects (read+write, write+send, etc.).--minsets the threshold.mix reach.concurrency- Task.async/await pairing, process monitors, spawn/link chains, and supervisor topology.
- Plugin
classify_effect/1callback - plugins can now teach the effect classifier about framework-specific calls. Implemented for all 8 built-in plugins (Phoenix, Ecto, Oban, GenStage, Jido, OpenTelemetry, JSON). - Positional path filter on all analysis commands - scope output to
specific files or directories (e.g.
mix reach.hotspots lib/my_app/). - Elixir 1.19+ inferred type classification - reads ExCk BEAM chunk
for compiler-inferred type signatures. Functions returning data types
are classified as
:pure. Gracefully disabled on older Elixir versions.
Improved
Fixed
Function resolution - correctly resolve functions when module name casing differs from the source (e.g. QuickBEAM.Runtime vs Quickbeam.Runtime). Also handles projects where IR nodes store modules as nil by falling back to file path matching.
Alias resolution -
alias Plausible.Ingestion.EventthenEvent.build()now correctly resolves toPlausible.Ingestion.Event. Handles simple aliases,:asaliases, and multi-alias{}syntax. Scoped per module - aliases don't leak acrossdefmoduleboundaries.Import resolution -
import Ecto.Querythen barefrom(...)now resolves toEcto.Query.from. Handles:onlyand:exceptoptions. Gracefully skips unloaded modules.Field access detection -
socket.assigns,conn.params,state.countare recognized as field access (kind: :field_access) instead of remote calls with a fake module name. Classified as:pure.Compile-time noise filtering -
@doc,@spec,@type,use,import,alias,require,::,__aliases__, typespec names, and binary syntax are classified as:pureinstead of:unknown.Local function effect inference - fixed-point iteration over function bodies infers effects from callees. Propagates across module boundaries via module-qualified cache keys.
Expanded pure modules -
Access,Calendar,Date,DateTime,NaiveDateTime,Time.Reclassified stdlib functions:
Kernel.to_stringand other builtins classified correctly when module is explicit.Enum.each→:io(was:unknown).Application.get_env,System.get_env→:read.System.monotonic_time,Mix.env→:read.GenServer.start_link,Supervisor.start_link→:io.Supervisor.child_spec→:pure.:atomics/:counters→:read/:write(was:nif).:persistent_term→:read/:write(was:nif).:no_returnand:stringrecognized as pure return types in specs.
Plugin effect classification:
- Phoenix: route helpers,
assign,push_event,attr,slot,sigil_H, router DSL →:pure. - Ecto: query DSL →
:pure, Repo reads →:read, writes →:write, changeset/schema macros →:pure. - Oban:
Oban.insert→:write,start_link/drain_queue→:io. - GenStage:
call/cast→:send, Broadway.Message →:pure. - Jido: updated to v2 API. Signal dispatch →
:send, directives →:io/:send, Thread →:pure, memory →:read/:write. - OpenTelemetry: Tracer spans →
:io, context →:read/:write,:telemetry.execute→:io. - JSON: all Jason/Poison functions →
:pure.
- Phoenix: route helpers,
Boxart integration:
reach.otp --graphusesBoxart.Render.StateDiagramfor GenServer state machines.reach.effects --graphusesBoxart.Render.PieChartfor effect distribution.- Upgraded boxart to 0.3.2.
Clause breakdown in
reach.hotspotsandreach.depth- multi-clause functions show dispatch labels (e.g. "53 clauses: save, delete, ...").Shared helpers - clause_labels/1 and
Format.threshold_color/3extracted from duplicated code.Unknown call ratio dropped from ~89% to ~11% across real codebases (tested on Plausible, Livebook, Tymeslot, OpenPace, Beacon, Ecto, Oban).
Performance
- ~30% faster project analysis (Plausible 466 files: 2.7s → 1.9s).
- Reach.Graph.merge/1 - direct map merge instead of per-edge
Graph.add_edgesloop. HigherOrder.add_edges- movedpure_call?typespec check from hot path to one-time catalog build.- Eliminated redundant
IR.all_nodestraversals in SDG build and effect inference. - Stored
func_defdirectly in PDG map, avoiding linear scan inbuild_external_sdg_map.
1.4.1
Fixed
mix reach.modules,mix reach.impact, andmix reach.slicecrash withBadBooleanErrorwhen--graphis not passed (closes #6).
1.4.0
New
- Terminal graph rendering via optional
boxartdependency:mix reach.graph Mod.fun/arity- control flow graph with syntax-highlighted source code and line numbers in each nodemix reach.graph Mod.fun/arity --call-graph- callee tree as mindmapmix reach.deps Mod.fun/arity --graph- callee tree visualizationmix reach.impact Mod.fun/arity --graph- caller tree visualizationmix reach.modules --graph- module dependency graph (internal only)mix reach.otp --graph- GenServer state diagrams per modulemix reach.slice file:line --graph- slice subgraph
Improved
Fixed
Function resolution - correctly resolve functions when module name casing differs from the source (e.g. QuickBEAM.Runtime vs Quickbeam.Runtime). Also handles projects where IR nodes store modules as nil by falling back to file path matching.
CFG rendering reuses
Visualize.ControlFlow.build_function/2- same line ranges, block merging, and source extraction as the HTML visualizationGraph output clamped to terminal width via
Boxart.render max_widthCFG code blocks dedented to match HTML visualization indentation
Same-line CFG vertices merged (no more duplicate nodes)
1.3.0
New
- 8 mix tasks for code analysis - agent-oriented CLI tools that expose
Reach's graph analysis as structured text/JSON output:
mix reach.modules- bird's-eye codebase inventory sorted by name/functions/complexity, with OTP/LiveView behaviour detectionmix reach.dead_code- find unused pure expressions (parallel per-file)mix reach.deps- direct callers, callee tree, shared state writersmix reach.impact- transitive callers, return value dependents, riskmix reach.flow- taint analysis (--from/--to) and variable tracingmix reach.slice- backward/forward program slicing by file:linemix reach.otp- GenServer state machines, ETS/process dictionary coupling, missing message handlers, supervision treemix reach.smell- cross-function performance anti-patterns (redundant traversals, duplicate computations, eager patterns)- All tools support
--format text(colored),json, andoneline
- Dynamic dispatch in Elixir frontend -
handler.(args)andfun.(args)now emit:callnodes withkind: :dynamic(closes #4) - ANSI color output - headers cyan, function names bright, file paths faint, complexity colored by severity, OTP state actions colored by type. Auto-disabled when piped.
Fixed
- BEAM frontend source_span normalization -
:erl_annoannotations (integer,{line, col}tuple, keyword list, or nil) now normalized via:erl_anno.line/1and:erl_anno.column/1.start_lineis always integer or nil. Column info extracted from{line, col}tuples (closes #5). - Visualization crash on BEAM modules -
build_def_line_mapandcached_file_linesnow skip non-source files and validate UTF-8. - dead_code false positives reduced 91% (628 → 58 on Phoenix) -
fixed-point alive expansion for intermediate variables, branch-tail return
tracing through case/cond/try/fn, guard exclusion, comprehension
generator/filter exclusion, cond condition exclusion,
<>pattern recognition, impure module blocklist (Process,:code,:ets,Node,System, etc.), typespec exclusion, impure call descendant marking. - reach.smell false positives - structural pipe check instead of transitive graph reachability, per-clause redundant computation grouping, full argument comparison (vars + literals), type-check function exclusion, function reference filtering, callback purity check for map→map fusion.
- reach.otp state detection - finds struct field access (
state.field), unwraps%State{} = statepatterns, detects ETS writes through state parameter. No longer flagsMap.mergeon non-state variables. - reach.deps shows only direct callers (transitive analysis in reach.impact).
- Block quality -
compute_vertex_rangesusesmin_line_in_subtreeto include multi-line pattern children.
Improved
Fixed
Function resolution - correctly resolve functions when module name casing differs from the source (e.g. QuickBEAM.Runtime vs Quickbeam.Runtime). Also handles projects where IR nodes store modules as nil by falling back to file path matching.
Performance - effect classification cached in ETS (shared across parallel tasks), SDG construction parallelized across modules. Livebook analysis: 9.7s → 3.5s.
Consistent CLI output -
(none found)everywhere, descriptive match descriptions (name = Module.func is unused), empty slice suggests--forward, zero-function modules filtered from reach.modules.
1.2.0
New
- Gleam support - analyze
.gleamsource files with accurate line mapping. Uses theglanceparser (Gleam's own parser, written in Gleam) for native AST parsing with byte-offset spans. Supports case expressions, pattern matching with guards, pipes, anonymous functions, record updates, and all standard Gleam constructs. Requiresgleam buildand glance on the code path.
Fixed
- Unreachable
block_label/5catch-all clause removed (Dialyzer). file_to_graph/2cyclomatic complexity reduced - extractedparse_file_and_build/3andread_and_build_elixir/2.func_end_line/2simplified - extractedfind_nearest_end/2.apply/3used for optional:glancemodule to avoid compile-time warnings.- Empty blocks from line clamping filtered out in visualization.
- Exit nodes show label in Vue component (no more invisible gray bars).
- Block end_line uses max across all vertices (fixes multi-line heredoc coverage).
- Block overlap elimination - end_line clamping considers all blocks globally.
repo_module?/1crash on capture syntax& &1.name(closes #3).
1.1.3
Fixed
- Crash in
Reach.Plugins.Ecto.repo_module?/1on capture syntax like& &1.namewhere call meta contains AST tuples instead of atoms (closes #3). - Block end_line now uses max across all vertices in the block, fixing missing coverage for multi-line heredoc strings inside if/case branches.
- Block overlap elimination - end_line clamping now considers all blocks globally, not just the next one in traversal order (155 → 0 overlaps).
- Multi-clause dispatch functions now connect exit nodes via
find_exit_predecessorsinstead of leaving them disconnected.
Audited
16,047 functions across 1,213 files (Elixir, Phoenix, Ecto, Oban, Plausible, Livebook): 0 empty blocks, 0 nil labels, 0 overlaps, 0 duplicate lines, 0 missing exits, 0 disconnected exits.
1.1.2
Fixed
- Anonymous fn bodies inlined into parent CFG -
Enum.reduce(fn ... end)callbacks with internal branching (case/if/raise) are now decomposed into visible control flow blocks instead of being opaque single nodes. - Block merging for same-line nested constructs - inline
if x, do: a, else: bno longer creates monster merged blocks likeb_1490_1485_1489_1478. Branch detection now includes clause targets and multi-out vertices. - Source extraction for clauses without source spans - multi-clause function
heads (e.g.
def foo(:join, :inner)) that lack compiler source spans now show source code via child node line walking instead of empty gray blocks. - Block disjointness - overlapping blocks eliminated (533 → 0) by clamping
block end_line to
(next_block_start - 1)and removing duplicate line ranges from dispatch clause blocks. - Missing exit nodes - multi-clause dispatch functions now include proper exit nodes (58 missing → 0).
- Pure pattern-matching dispatches - functions like
join_qual/1with 9 one-line clauses render as a single function node instead of a useless dispatch → 9 disconnected clause blocks. - Preamble/sidebar spam removed - the sidebar no longer shows
@doc,@moduledoc,use,importlines extracted by string matching. Sidebar shows only module name and function list. - Render patterns added for
:pin,:binary_op,:unary_op,:cons,:guard,:generator,:filter,:module_defnode types.
Added
- Block quality audit test - validates 6 acceptance criteria (coverage, disjointness, no empty blocks, no nil labels, entry/exit structure) across real codebases (Ecto, Phoenix, Oban).
1.1.1
Fixed
- Crash (
FunctionClauseError) on macro definitions that look likeif/unlesscalls with non-keyword-list branches (e.g.defmacro if(condition, clauses)in Elixir's ownKernel). - Crash when
case/cond/receivedo-blocks containunquotesplices instead of a normal clause list (macro-heavy code likeMacro,ExUnit.Callbacks). hd([])crash in control-flow builder on code with empty exit sets (e.g. single-clausecond do true -> :ok end).- Struct/map pattern rendering crash with field bindings (from PR #2).
Tested
Smoke-tested on 3,024 files across 8 major Elixir projects with zero failures: elixir, phoenix, ecto, oban, plausible, livebook, blockscout, firezone.
1.1.0
New
- Plugin system -
Reach.Pluginbehaviour for library-specific analysis. Auto-detects Phoenix, Ecto, Oban, GenStage, Jido, and OpenTelemetry at runtime. Override withplugins:option. mix reachtask - generates self-contained interactive HTML report with three visualization modes: Control Flow, Call Graph, and Data Flow.- Expression-level control flow graph - source-primary visualization where every line of every function is visible. Branch points (if/case/unless) show fan-out edges, all paths converge at merge points with blue converge edges.
- Core CFG expansion - Reach.ControlFlow.build/1 now correctly expands
branches nested inside pipes, assignments, and calls.
if ... end |> f()shows both branches converging at the pipe call. - Intra-function data flow - Data Flow tab shows variable def→use chains within each function, labeled with variable names.
- Module preamble - sidebar shows
use/import/alias/@attributesas a collapsed header, not separate nodes. - Syntax highlighting - Makeup-powered server-side highlighting in all code blocks, with proper indentation preservation via common-prefix dedent.
- Multi-clause dispatch - pattern-match dispatch nodes with colored
clause edges for functions with multiple
defheads. - 6 built-in plugins: Phoenix, Ecto, Oban, GenStage, Jido, OpenTelemetry.
Improved
Fixed
Function resolution - correctly resolve functions when module name casing differs from the source (e.g. QuickBEAM.Runtime vs Quickbeam.Runtime). Also handles projects where IR nodes store modules as nil by falling back to file path matching.
Call graph - filtered Ecto query bindings, pipe operators, kernel ops, Ecto DSL macros; nil module resolved to detected module; deduplicated edges.
Text selection - code blocks now allow text selection (
user-select: text, nodes not draggable).Sidebar navigation - click scrolls and zooms to function, highlights all nodes of selected function with blue glow, dims others. Click background to clear.
Fixed
- Crash on
key :start_line not foundwhen processing branch expressions. - Node ID collisions in
Reach.Projectparallel file parsing (shared:atomicscounter). - Module name extraction fallback when path has no
lib/srcprefix.
1.0.0
First public release.
Core analysis
- Program Dependence Graph - builds a graph capturing data and control dependencies for Elixir and Erlang source code. Every expression knows what it depends on and what depends on it.
- Scope-aware data dependence - variable definitions resolve through lexical scope chains. Variables in case clauses, fn bodies, and comprehensions don't leak to sibling scopes.
- Binding role tracking - pattern variables (
xinx = foo(),{a, b}) are tagged as definitions at IR construction time. Data edges go from definition vars to use vars, not from clauses. - Match binding edges -
z = x ++ ycreates:match_bindingedges from the RHS expression to the LHS definition var, enabling transitive data flow through assignments. - Containment edges - parent expressions depend on their child
sub-expressions.
backward_slice(x + 1)reaches bothxand1. - Multi-clause function grouping -
def foo(:a)+def foo(:b)in the same module are merged into one function definition with proper dispatch control flow (:clause_match/:clause_failedges).
Three frontends
- Elixir source -
Reach.string_to_graph/2,Reach.file_to_graph/2. Handles all Elixir constructs: match, case, cond, with/else, try/rescue/after, receive/timeout, for comprehensions, pipe chains (desugared), capture operators (&fun/1,&Mod.fun/1,&(&1 + 1)),if/unless(desugared to case), guards, anonymous functions, structs, maps, dot access on variables. - Erlang source -
Reach.string_to_graph(source, language: :erlang), auto-detected for.erlfiles. Parses via:epp, translates Erlang abstract forms to the same IR. - BEAM bytecode -
Reach.module_to_graph/2,Reach.compiled_to_graph/2. Analyzes macro-expanded code from compiled.beamfiles. Seesuse GenServerinjected callbacks, macro-expandedtry/rescue, generated functions.
Slicing and queries
Reach.backward_slice/2- what affects this expression?Reach.forward_slice/2- what does this expression affect?Reach.chop/3- nodes on all paths from A to B.Reach.independent?/3- can two expressions be safely reordered? Checks data flow (including descendant nodes), control dependencies, and side effect conflicts.Reach.nodes/2- filter nodes by:type,:module,:function,:arity.Reach.neighbors/3- direct neighbors with optional label filter.Reach.data_flows?/3- does data flow from source to sink? Checks descendant nodes of both source and sink.Reach.depends?/3,Reach.has_dependents?/2,Reach.controls?/3.Reach.passes_through?/4- does the flow path pass through a node matching a predicate?
Taint analysis
Reach.taint_analysis/2- declarative taint analysis with keyword filters (same format asnodes/2) or predicate functions. Returns source, sink, path, and whether sanitization was found.
Dead code detection
Reach.dead_code/1- finds pure expressions whose values are never used and don't contribute to any observable output (return values or side-effecting calls). Excludes module attributes, typespecs, and vars (compiler handles those).
Effect classification
Reach.pure?/1,Reach.classify_effect/1- classifies calls as:pure,:io,:read,:write,:send,:receive,:exception,:nif, or:unknown.- Hardcoded database covers 30+ pure modules (Enum, Map, List, String, etc.) plus Erlang equivalents.
Enum.eachcorrectly classified as impure.- Type-aware inference - functions not in the hardcoded database are
auto-classified by extracting
@specviaCode.Typespec.fetch_specs. Functions returning only data types are inferred as pure; functions returning:okare left as unknown.
Higher-order function resolution
- Auto-generated catalog of 1,000+ functions from pure modules where parameters flow to return value. Covers Enum, Stream, Map, String, List, Keyword, Tuple, and Erlang equivalents.
:higher_orderedges connect flowing arguments to call results.- Impure functions (like
Enum.each) excluded - their param flow is for side effects, not return value production.
Interprocedural analysis
- Call graph -
{module, function, arity}vertices with call edges. - System Dependence Graph - per-function PDGs connected through
:call,:parameter_in,:parameter_out, and:summaryedges. - Context-sensitive slicing - Horwitz-Reps-Binkley two-phase algorithm avoids impossible paths through call sites.
- Cross-module resolution -
Reach.Projectlinks call edges across modules and applies external dependency summaries.
OTP awareness
- GenServer state threading -
:state_readedges from callback state parameter to uses,:state_passedges between consecutive callback returns. - Message content flow -
send(pid, {:tag, data})creates{:message_content, :tag}edges tohandle_info({:tag, payload})pattern vars. Tags must match. - GenServer.call reply flow -
{:reply, value, state}creates:call_replyedges from reply value back toGenServer.callcall site. - ETS dependencies -
{:ets_dep, table}edges between writes and reads on the same table, with table name tracking. - Process dictionary -
{:pdict_dep, key}edges betweenProcess.putandProcess.geton the same key. - Message ordering -
:message_orderedges between sequential sends to the same target pid.
Concurrency analysis
- Process.monitor → :DOWN -
:monitor_downedges from monitor calls tohandle_info({:DOWN, ...})handlers in the same module. - trap_exit → :EXIT -
:trap_exitedges fromProcess.flag(:trap_exit)tohandle_info({:EXIT, ...})handlers. - spawn_link / Process.link -
:link_exitedges to:EXIThandlers. - Task.async → Task.await -
:task_resultedges paired by module scope and position order. - Supervisor children -
:startup_orderedges from child ordering ininit/1.
Multi-file project analysis
Reach.Project.from_sources/2,from_glob/2,from_mix_project/1- parallel file parsing, cross-module call resolution, merged project graph.Reach.Project.taint_analysis/2- taint analysis across all modules.Reach.Project.summarize_dependency/1- compute param→return flow summaries for compiled dependency modules.
Canonical ordering
Reach.canonical_order/2- sorts block children so independent siblings have deterministic order regardless of source order. Dependent expressions preserve relative order. Enables Type IV reordering-equivalent clone detection in ExDNA.
Integration
Reach.ast_to_graph/2- build graph from pre-parsed Elixir AST (for Credo/ExDNA integration, no re-parsing).Reach.to_graph/1- returns the underlyingGraph.t()(libgraph) for power users who need path finding, subgraphs, BFS/DFS, etc.Reach.to_dot/1- Graphviz DOT export.
Performance
Benchmarked on real projects (Apple M1 Pro):
| Project | Files | Time |
|---|---|---|
| ex_slop | 26 | 36ms |
| ex_dna | 32 | 87ms |
| Livebook | 72 | 160ms |
| Oban | 64 | 195ms |
| Keila | 190 | 282ms |
| Phoenix | 74 | 333ms |
| Absinthe | 282 | 375ms |
740 files, zero crashes.