Soundcheck

Soundcheck is a Credo plugin for Phoenix LiveView. It finds bugs by checking how callbacks, HEEx templates, and JavaScript hooks work together: an event with no handler, an async task with no result callback, or an assign missing from the first render.

Some checks analyse source code directly. Others read compiled modules to resolve event handlers, components, routes, and mount hooks across files.

Warning

This project is experimental and might break frequently.

A set of high-confidence checks runs by default. All other checks are opt-in; the checks table lists which are enabled. When Soundcheck cannot resolve a dynamic value or external hook, it can report an unverifiable finding. These findings appear only when you enable Soundcheck's strict parameter or pass mix soundcheck --strict.

The name comes from the check musicians run before going live.

Installation

Supported versions: Elixir 1.18 or later, Phoenix 1.7 and 1.8, LiveView 1.1 and 1.2, and Credo 1.7.

With Igniter

If your project uses Igniter, run:

mix igniter.install soundcheck@0.1.0

The installer adds Soundcheck as a dependency with only: [:dev, :test], runtime: false, registers the plugin in .credo.exs, and adds a lint alias that compiles before running Credo. If you already have a lint alias, the installer leaves it as it is; make sure it runs compile before credo.

Manual

Add Soundcheck to your dependencies in mix.exs:

def deps do
  [
    {:soundcheck, "~> 0.1.0", only: [:dev, :test], runtime: false}
  ]
end

Fetch the dependency:

mix deps.get

Then add the plugin to your existing .credo.exs configuration:

%{
  configs: [
    %{
      name: "default",
      plugins: [{Soundcheck, []}]
    }
  ]
}

Running with Credo

Compile your project before running Credo:

mix compile
mix credo

For a repeatable workflow, add a Mix alias:

# mix.exs
defp aliases do
  [lint: ["compile", "credo --strict"]]
end

Then run mix lint. Credo's --strict option controls Credo's reporting; to include Soundcheck's unverifiable findings, set strict: true on the relevant checks in .credo.exs.

Checks marked semantic require a compiled project. They read handler clauses from module debug info, resolve on_mount hooks, and identify the components each template renders. If a module cannot be analysed, the Configuration check (SC000) reports the reason and the affected semantic checks stay silent. Checks marked syntactic work without compiling.

Checks

ID Check Category Default Tier Confidence What it catches
SC000 Soundcheck.Check.Consistency.Configuration consistency on semantic high LiveView not loaded, LiveView modules that cannot load or carry no debug info
SC001 Soundcheck.Check.Readability.InvalidSuppression readability on syntactic high soundcheck:disable-* comments naming no rule or an unknown one; with strict, comments without a reason
SC101 Soundcheck.Check.Warning.UnhandledEvent warning on semantic high Template events (phx-*, JS.push) with no handle_event/3 clause in the receiving LiveView or LiveComponent
SC102 Soundcheck.Check.Design.UnusedEventHandler design off semantic medium handle_event/3 clauses nothing in the project emits
SC103 Soundcheck.Check.Warning.UnhandledHookEvent warning off semantic medium pushEvent/pushEventTo calls in hooks that the receiving LiveView does not handle
SC104 Soundcheck.Check.Warning.UnhandledAsyncResult warning on semantic high start_async/3 names with no matching handle_async/3 clause
SC105 Soundcheck.Check.Warning.FormFieldMismatch warning off syntactic medium handle_event/3 patterns that require a form field the form never sends
SC106 Soundcheck.Check.Warning.UnhandledServerPushEvent warning off syntactic medium push_event/3 events no hook handleEvent or phx: window listener receives
SC107 Soundcheck.Check.Warning.UnhandledInfoMessage warning off semantic high Messages a LiveView sends itself, or a LiveComponent sends its parent LiveView, with no matching handle_info/2 clause
SC108 Soundcheck.Check.Warning.EventPayloadMismatch warning off semantic medium phx-click events whose phx-value-* and JS.push values lack a key every matching handle_event/3 clause requires
SC201 Soundcheck.Check.Warning.PossiblyUnsetAssign warning off semantic medium Template reads of assigns mount/3 does not set on every path before the first render
SC202 Soundcheck.Check.Warning.AssignSetOnlyWhenConnected warning on semantic high Template reads of assigns mount/3 sets only when connected, which crash the first, disconnected render
SC203 Soundcheck.Check.Warning.UndeclaredComponentAttr warning on syntactic high @key reads in function components that declare attrs but not key
SC204 Soundcheck.Check.Warning.OptionalAttrWithoutDefault warning off syntactic medium @key reads of optional attrs without a default, which raise when a caller omits them
SC205 Soundcheck.Check.Design.UnreadAssign design off syntactic medium Assign keys no template, layout, or callback reads
SC206 Soundcheck.Check.Warning.AsyncAssignFieldRead warning on syntactic high Template reads like @org.name of assign_async assigns, which hold an AsyncResult and raise on render
SC301 Soundcheck.Check.Refactor.BareAssignsInTemplate refactor off syntactic high The bare assigns in template expressions, which disables change tracking
SC302 Soundcheck.Check.Readability.AssignsDotAccess readability off syntactic high assigns.key where @key is idiomatic
SC303 Soundcheck.Check.Refactor.TemplateBoundVariable refactor off syntactic medium Variables bound in templates with <% x = ... %> and read later, which disables change tracking
SC304 Soundcheck.Check.Warning.StreamContainerWithoutPhxUpdate warning on syntactic high Stream loops whose parent element lacks phx-update="stream"
SC305 Soundcheck.Check.Warning.StreamChildWithoutId warning on syntactic high Children of stream containers without an id={...} from the stream
SC306 Soundcheck.Check.Warning.StreamConsumedOutsideFor warning on syntactic high @streams.name passed to Enum, length/1, and other enumerating functions
SC307 Soundcheck.Check.Refactor.StreamCandidate refactor off syntactic low List assigns grown one item at a time and rendered with :for
SC308 Soundcheck.Check.Refactor.TemporaryAssignCandidate refactor off syntactic low List assigns replaced whole, rendered once with :for, and never read in code
SC309 Soundcheck.Check.Refactor.ExpensiveCallInTemplate refactor off syntactic medium Database, HTTP, and sorting calls inside template expressions
SC310 Soundcheck.Check.Warning.UndefinedStream warning off syntactic medium @streams.name reads and stream_insert/stream_delete calls on stream names the module never defines
SC311 Soundcheck.Check.Warning.InvalidLiveComponent warning on semantic high <.live_component> calls without id or module, or whose module is not a LiveComponent, which raise on render
SC312 Soundcheck.Check.Warning.InvalidLiveRender warning on semantic high Nested live_render(socket, ...) calls without an :id, or of a module that is not a LiveView, which raise on render
SC313 Soundcheck.Check.Warning.LiteralLiveComponentIdInLoop warning on syntactic high <.live_component> with a literal id inside a :for loop, which raises once the loop renders two items
SC401 Soundcheck.Check.Warning.UnscopedLookupByClientParam warning off syntactic medium Repo and context lookups (Blog.get_post!(id)) by client-sent event or URL params in clauses that never consult the current user or scope
SC402 Soundcheck.Check.Warning.LiveRouteWithoutAuthHook warning off semantic high Live routes whose live_session and LiveView run no authentication on_mount hook that can halt the mount
SC501 Soundcheck.Check.Warning.UnguardedSubscribe warning on syntactic high PubSub subscriptions in mount/3 outside connected?(socket)
SC502 Soundcheck.Check.Refactor.ExpensiveMountWork refactor off syntactic medium Database and HTTP calls in mount/3 that run for both mounts
SC503 Soundcheck.Check.Warning.UndefinedMountHook warning on semantic high on_mount hooks whose module has no on_mount/4 clause for the hook's argument, which crash every mount they guard
SC601 Soundcheck.Check.Warning.SubscribedWithoutHandleInfo warning on semantic high LiveViews that subscribe in mount/3 but define no handle_info/2
SC602 Soundcheck.Check.Warning.BlockingCallInHandler warning off syntactic medium Sleeps, awaits, HTTP calls, and unbounded receive in event, info, and params handlers
SC603 Soundcheck.Check.Warning.CrashingMatchInCallback warning off syntactic medium {:ok, _} = call() matches in LiveView callbacks, which crash the process and lose unsaved state when the call fails
SC604 Soundcheck.Check.Warning.LiveViewCallbackInComponent warning on syntactic high LiveView-only callbacks (handle_info/2, mount/3, handle_params/3, ...) in a LiveComponent, which LiveView never calls
SC605 Soundcheck.Check.Warning.InvalidSendUpdate warning on semantic high send_update calls without an :id, or targeting a module that is not a LiveComponent, whose update raises or goes nowhere
SC701 Soundcheck.Check.Warning.PatchToForeignLiveView warning on semantic high push_patch to a route another LiveView renders, which raises
SC702 Soundcheck.Check.Refactor.NavigateAcrossLiveSession refactor off semantic low push_navigate, <.link navigate>, and JS.navigate into another live_session, which reloads the page
SC703 Soundcheck.Check.Warning.PatchWithoutHandleParams warning on semantic high push_patch, <.link patch>, and JS.patch back to a LiveView that defines no handle_params/3, which raises
SC801 Soundcheck.Check.Refactor.FormForChangeset refactor off syntactic medium Forms built from a changeset assign instead of to_form/2
SC802 Soundcheck.Check.Refactor.ChangeWithoutDebounce refactor off syntactic medium Text inputs in phx-change forms without phx-debounce
SC803 Soundcheck.Check.Warning.FormWithoutRecovery warning off syntactic medium phx-submit forms without an id or phx-change, whose input LiveView cannot restore after a reconnect
SC804 Soundcheck.Check.Warning.UndefinedUpload warning off syntactic medium @uploads.name reads and consume_uploaded_entries and other upload calls on names the module never allows
SC901 Soundcheck.Check.Design.ClickOnNonInteractiveElement design off syntactic high phx-click on non-interactive elements without role or keyboard support
SC1001 Soundcheck.Check.Warning.UndefinedHook warning on syntactic high phx-hook names LiveSocket does not register
SC1002 Soundcheck.Check.Design.UnusedHook design off syntactic medium Hooks registered with LiveSocket that no template uses; Credo reports these against the JavaScript file
SC1003 Soundcheck.Check.Warning.HookWithoutId warning on syntactic high HTML elements with phx-hook but no id, whose hook LiveView never mounts

Use mix credo explain <file>:<line> for a check's full explanation and before/after examples.

Running with mix soundcheck

The standalone task compiles your project and runs the same checks, with text, JSON, or SARIF output. It shows every location involved in a finding, including exact locations in HEEx and JavaScript files. SARIF output includes related locations for code scanning.

mix soundcheck                      # default checks, plus .credo.exs overrides
mix soundcheck --all --strict       # every check, including unverifiable findings
mix soundcheck --only SC101,SC1002   # selected checks
mix soundcheck --format sarif --output soundcheck.sarif

Example output:

SC101 warning: `phx-click="add-trak"` has no matching `handle_event/3` clause in `MyAppWeb.AlbumLive`.
   --> lib/my_app_web/live/album_live.ex:127:30
    |
127 |     <.button phx-click="add-trak">Add track</.button>
    |                        ^^^^^^^^
   = help: Did you mean "add-track"?

The task reads check parameters, enabled and disabled checks, and the plugin's js_paths from .credo.exs. --all and --only override check selection while retaining configured parameters. It exits with status 1 if it reports any finding that is not unverifiable.

Command options

Option Meaning
--all Run every check, including opt-in checks
--only SC101,SC1002 Run only the listed rule IDs
--strict Include unverifiable findings
--format text|json|sarif Output format; defaults to text
--output FILE Write output to a file instead of standard output
--path GLOB Elixir source glob; repeatable; defaults to lib/**/*.ex
--js-path DIR JavaScript source directory; repeatable; overrides configured js_paths
--config-file FILE Credo configuration file; defaults to .credo.exs
--config-name NAME Named Credo configuration; defaults to default
--no-compile Use the existing build without recompiling
--baseline FILE Use a specific baseline file
--no-baseline Report findings even if they are in the baseline
--write-baseline Record current findings in the baseline and exit with status 0

Configuration

Enabling and disabling checks

Add opt-in checks to checks.extra in .credo.exs. Use false to disable a check, or a keyword list to enable it with parameters:

%{
  configs: [
    %{
      name: "default",
      plugins: [{Soundcheck, []}],
      checks: %{
        extra: [
          # Enable an opt-in check
          {Soundcheck.Check.Design.UnusedEventHandler, []},

          # Customise an enabled check
          {Soundcheck.Check.Warning.UnhandledEvent, [ignore_events: [~r/^analytics-/]]},

          # Disable a default check
          {Soundcheck.Check.Warning.UndefinedHook, false}
        ]
      }
    }
  ]
}

To enable every opt-in check in Credo, use the following checks section. Default checks need no additional entries. For a standalone run, use mix soundcheck --all.

checks: %{
  extra: [
    {Soundcheck.Check.Design.ClickOnNonInteractiveElement, []},
    {Soundcheck.Check.Design.UnreadAssign, []},
    {Soundcheck.Check.Design.UnusedEventHandler, []},
    {Soundcheck.Check.Design.UnusedHook, []},
    {Soundcheck.Check.Readability.AssignsDotAccess, []},
    {Soundcheck.Check.Refactor.BareAssignsInTemplate, []},
    {Soundcheck.Check.Refactor.ChangeWithoutDebounce, []},
    {Soundcheck.Check.Refactor.ExpensiveCallInTemplate, []},
    {Soundcheck.Check.Refactor.ExpensiveMountWork, []},
    {Soundcheck.Check.Refactor.FormForChangeset, []},
    {Soundcheck.Check.Refactor.NavigateAcrossLiveSession, []},
    {Soundcheck.Check.Refactor.StreamCandidate, []},
    {Soundcheck.Check.Refactor.TemplateBoundVariable, []},
    {Soundcheck.Check.Refactor.TemporaryAssignCandidate, []},
    {Soundcheck.Check.Warning.BlockingCallInHandler, []},
    {Soundcheck.Check.Warning.CrashingMatchInCallback, []},
    {Soundcheck.Check.Warning.EventPayloadMismatch, []},
    {Soundcheck.Check.Warning.FormFieldMismatch, []},
    {Soundcheck.Check.Warning.FormWithoutRecovery, []},
    {Soundcheck.Check.Warning.LiveRouteWithoutAuthHook, []},
    {Soundcheck.Check.Warning.OptionalAttrWithoutDefault, []},
    {Soundcheck.Check.Warning.PossiblyUnsetAssign, []},
    {Soundcheck.Check.Warning.UndefinedStream, []},
    {Soundcheck.Check.Warning.UndefinedUpload, []},
    {Soundcheck.Check.Warning.UnhandledHookEvent, []},
    {Soundcheck.Check.Warning.UnhandledInfoMessage, []},
    {Soundcheck.Check.Warning.UnhandledServerPushEvent, []},
    {Soundcheck.Check.Warning.UnscopedLookupByClientParam, []}
  ]
}

JavaScript sources

The plugin scans assets/js for .js, .ts, and .mjs hook sources by default. Set js_paths to scan other directories:

plugins: [{Soundcheck, [js_paths: ["assets/js", "assets/vendor/hooks"]]}]

Shared check parameters

Every check accepts:

Parameter Default Meaning
excluded_paths [~r"/test/", "test"] Files to skip. Strings match whole path segments or full file paths; "test" skips test/foo.ex, but not contest/foo.ex. Regexes match the full filename.
strict false Include unverifiable findings. For SC001, also require a reason in suppression comments.

Check-specific parameters

Check names below omit the Soundcheck.Check.<Category>. prefix. Defaults containing ... are abbreviated; use mix credo explain <file>:<line> for the full check explanation.

Check Param Default Meaning
UnhandledEvent, UnusedEventHandler ignore_events [] Event names to skip: binaries (exact) or regexes
EventPayloadMismatch metadata_keys [] Keys added to click payloads by LiveSocket metadata callbacks
PossiblyUnsetAssign assume_set [:current_user, :current_scope, :flash, :live_action, :page_title] Keys the team's conventions guarantee before the first render
UnreadAssign ignore_assigns [:page_title] Keys never reported
UndeclaredComponentAttr implicit_assigns [:inner_block, :rest, :__changed__, :__given__] Assign keys never reported
UnguardedSubscribe, SubscribedWithoutHandleInfo subscribe_functions [~r/^Phoenix\.PubSub\.subscribe$/, ~r/\.subscribe$/] Subscribing functions, matched against Module.function with aliases expanded
ExpensiveCallInTemplate expensive_modules, expensive_functions [~r/\.Repo$/, ~r/^Ecto\.Query$/, ~r/^(Req|Finch|HTTPoison|Tesla)$/], [{Enum, :sort}, ...] Calls that count as expensive in templates
ExpensiveMountWork expensive_modules [~r/\.Repo$/, ~r/^(Req|Finch|HTTPoison|Tesla)$/] Modules whose calls count as expensive in mount/3
BlockingCallInHandler blocking_functions, blocking_modules [{Process, :sleep}, {:timer, :sleep}, {Task, :await}, ...], [~r/^(Req|Finch|HTTPoison|Tesla)$/] Calls that block a handler
UnscopedLookupByClientParam repo_modules, scope_assigns, authorizers, excluded_modules [~r/\.Repo$/], [:current_scope, :current_user], [~r/authoriz/, ~r/permit/, ~r/can\?$/, ~r/allowed\?$/], [~r/Admin/] Repos to watch; assigns and calls that count as scoping a clause; modules to skip
UnscopedLookupByClientParam lookup_functions [~r/\.get_\w+!?$/, ~r/\.fetch_\w+!?$/] Context lookup functions in project modules, matched against Module.function
LiveRouteWithoutAuthHook auth_hooks, public_paths :auto, [] Hook modules that authenticate (:auto: names containing Auth, once any route uses one); route paths that need none
NavigateAcrossLiveSession ignore_sessions [] live_session names to skip as origin or target
ChangeWithoutDebounce input_types ["text", "search", "email", "textarea", "url", "tel"] Input types that count as text-like
ClickOnNonInteractiveElement non_interactive_tags ~w(div span li td tr img p section article) Tags that are not interactive by default
UndefinedHook external_hooks [] Hook names registered by code Soundcheck cannot follow; listing any closes an open registry

For example, to include unverifiable event findings while ignoring analytics events:

{Soundcheck.Check.Warning.UnhandledEvent, [ignore_events: [~r/^analytics-/], strict: true]}

Suppressing findings

For an intentional exception, add a soundcheck:disable-* comment with a reason. Suppressions work in Elixir, HEEx, and JavaScript, with both mix credo and mix soundcheck.

# soundcheck:disable-next-line SC107 -- sent by a test helper
<%!-- soundcheck:disable-next-line SC101 -- pushed by an external widget --%>
<div phx-click="external-event"></div>
// soundcheck:disable-line UnusedHook -- mounted by the admin bundle
Directive Scope
disable-next-line The following line
disable-line The comment's own line
disable-file The whole file

Selectors can be rule IDs (SC101), family wildcards (SC1xx), or check names (UnhandledEvent). Separate multiple selectors with commas or spaces. A suppression applies when it covers the finding's location or the line Credo reports it on.

SC001 reports missing or unknown selectors. With strict: true, it also reports suppression comments without a reason.

Adopting on an existing codebase

Use a baseline to record existing findings and report only new ones:

mix soundcheck --all --write-baseline

Commit the generated .soundcheck_baseline.exs. Both mix credo and mix soundcheck use it automatically.

Baseline fingerprints use the rule, file, and message, so moving a finding to another line does not make it new. Changing the finding itself does. Run the baseline command again after fixing findings to remove resolved entries. A run narrowed with --only or --path preserves entries outside its scope.

Use mix soundcheck --no-baseline to see all findings, or --baseline FILE to use a different baseline file.

How findings appear in Credo

Credo reports each finding at one location:

Finding location Where Credo reports it
Inside a ~H sigil The exact attribute in the Elixir file
In a .heex file The owning module's embed_templates call, or defmodule for a colocated LiveView template; the message includes the template location
A hook used by a template The phx-hook attribute; the message includes the JavaScript location
Only in JavaScript, such as an unused hook (SC1002) The JavaScript file, without a line number; the message starts with file:line:column

For JavaScript-only findings, use // soundcheck:disable-next-line to suppress them; Elixir # credo:disable comments cannot reach them. Each Credo issue carries the full finding in issue.meta[:soundcheck].

Use mix soundcheck when you need every related location or line-level JavaScript output.

Design

See docs/architecture.md for the architecture, planned rule catalog, and design decisions.

License

MIT. See LICENSE.