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

  • Default: on checks run automatically; off checks must be enabled in Configuration.
  • Tier: syntactic checks read source code; semantic checks also read compiled modules.
  • Confidence: how certain the check is about the pattern it detects.
IDCheckCategoryDefaultTierConfidenceWhat it catches
SC000Soundcheck.Check.Consistency.ConfigurationconsistencyonsemantichighLiveView not loaded, LiveView modules that cannot load or carry no debug info
SC001Soundcheck.Check.Readability.InvalidSuppressionreadabilityonsyntactichighsoundcheck:disable-* comments naming no rule or an unknown one; with strict, comments without a reason
SC101Soundcheck.Check.Warning.UnhandledEventwarningonsemantichighTemplate events (phx-*, JS.push) with no handle_event/3 clause in the receiving LiveView or LiveComponent
SC102Soundcheck.Check.Design.UnusedEventHandlerdesignoffsemanticmediumhandle_event/3 clauses nothing in the project emits
SC103Soundcheck.Check.Warning.UnhandledHookEventwarningoffsemanticmediumpushEvent/pushEventTo calls in hooks that the receiving LiveView does not handle
SC104Soundcheck.Check.Warning.UnhandledAsyncResultwarningonsemantichighstart_async/3 names with no matching handle_async/3 clause
SC105Soundcheck.Check.Warning.FormFieldMismatchwarningoffsyntacticmediumhandle_event/3 patterns that require a form field the form never sends
SC106Soundcheck.Check.Warning.UnhandledServerPushEventwarningoffsyntacticmediumpush_event/3 events no hook handleEvent or phx: window listener receives
SC107Soundcheck.Check.Warning.UnhandledInfoMessagewarningoffsemantichighMessages a LiveView sends itself, or a LiveComponent sends its parent LiveView, with no matching handle_info/2 clause
SC108Soundcheck.Check.Warning.EventPayloadMismatchwarningoffsemanticmediumphx-click events whose phx-value-* and JS.push values lack a key every matching handle_event/3 clause requires
SC201Soundcheck.Check.Warning.PossiblyUnsetAssignwarningoffsemanticmediumTemplate reads of assigns mount/3 does not set on every path before the first render
SC202Soundcheck.Check.Warning.AssignSetOnlyWhenConnectedwarningonsemantichighTemplate reads of assigns mount/3 sets only when connected, which crash the first, disconnected render
SC203Soundcheck.Check.Warning.UndeclaredComponentAttrwarningonsyntactichigh@key reads in function components that declare attrs but not key
SC204Soundcheck.Check.Warning.OptionalAttrWithoutDefaultwarningoffsyntacticmedium@key reads of optional attrs without a default, which raise when a caller omits them
SC205Soundcheck.Check.Design.UnreadAssigndesignoffsyntacticmediumAssign keys no template, layout, or callback reads
SC206Soundcheck.Check.Warning.AsyncAssignFieldReadwarningonsyntactichighTemplate reads like @org.name of assign_async assigns, which hold an AsyncResult and raise on render
SC301Soundcheck.Check.Refactor.BareAssignsInTemplaterefactoroffsyntactichighThe bare assigns in template expressions, which disables change tracking
SC302Soundcheck.Check.Readability.AssignsDotAccessreadabilityoffsyntactichighassigns.key where @key is idiomatic
SC303Soundcheck.Check.Refactor.TemplateBoundVariablerefactoroffsyntacticmediumVariables bound in templates with <% x = ... %> and read later, which disables change tracking
SC304Soundcheck.Check.Warning.StreamContainerWithoutPhxUpdatewarningonsyntactichighStream loops whose parent element lacks phx-update="stream"
SC305Soundcheck.Check.Warning.StreamChildWithoutIdwarningonsyntactichighChildren of stream containers without an id={...} from the stream
SC306Soundcheck.Check.Warning.StreamConsumedOutsideForwarningonsyntactichigh@streams.name passed to Enum, length/1, and other enumerating functions
SC307Soundcheck.Check.Refactor.StreamCandidaterefactoroffsyntacticlowList assigns grown one item at a time and rendered with :for
SC308Soundcheck.Check.Refactor.TemporaryAssignCandidaterefactoroffsyntacticlowList assigns replaced whole, rendered once with :for, and never read in code
SC309Soundcheck.Check.Refactor.ExpensiveCallInTemplaterefactoroffsyntacticmediumDatabase, HTTP, and sorting calls inside template expressions
SC310Soundcheck.Check.Warning.UndefinedStreamwarningoffsyntacticmedium@streams.name reads and stream_insert/stream_delete calls on stream names the module never defines
SC311Soundcheck.Check.Warning.InvalidLiveComponentwarningonsemantichigh<.live_component> calls without id or module, or whose module is not a LiveComponent, which raise on render
SC312Soundcheck.Check.Warning.InvalidLiveRenderwarningonsemantichighNested live_render(socket, ...) calls without an :id, or of a module that is not a LiveView, which raise on render
SC313Soundcheck.Check.Warning.LiteralLiveComponentIdInLoopwarningonsyntactichigh<.live_component> with a literal id inside a :for loop, which raises once the loop renders two items
SC401Soundcheck.Check.Warning.UnscopedLookupByClientParamwarningoffsyntacticmediumRepo and context lookups (Blog.get_post!(id)) by client-sent event or URL params in clauses that never consult the current user or scope
SC402Soundcheck.Check.Warning.LiveRouteWithoutAuthHookwarningoffsemantichighLive routes whose live_session and LiveView run no authentication on_mount hook that can halt the mount
SC501Soundcheck.Check.Warning.UnguardedSubscribewarningonsyntactichighPubSub subscriptions in mount/3 outside connected?(socket)
SC502Soundcheck.Check.Refactor.ExpensiveMountWorkrefactoroffsyntacticmediumDatabase and HTTP calls in mount/3 that run for both mounts
SC503Soundcheck.Check.Warning.UndefinedMountHookwarningonsemantichighon_mount hooks whose module has no on_mount/4 clause for the hook's argument, which crash every mount they guard
SC601Soundcheck.Check.Warning.SubscribedWithoutHandleInfowarningonsemantichighLiveViews that subscribe in mount/3 but define no handle_info/2
SC602Soundcheck.Check.Warning.BlockingCallInHandlerwarningoffsyntacticmediumSleeps, awaits, HTTP calls, and unbounded receive in event, info, and params handlers
SC603Soundcheck.Check.Warning.CrashingMatchInCallbackwarningoffsyntacticmedium{:ok, _} = call() matches in LiveView callbacks, which crash the process and lose unsaved state when the call fails
SC604Soundcheck.Check.Warning.LiveViewCallbackInComponentwarningonsyntactichighLiveView-only callbacks (handle_info/2, mount/3, handle_params/3, ...) in a LiveComponent, which LiveView never calls
SC605Soundcheck.Check.Warning.InvalidSendUpdatewarningonsemantichighsend_update calls without an :id, or targeting a module that is not a LiveComponent, whose update raises or goes nowhere
SC701Soundcheck.Check.Warning.PatchToForeignLiveViewwarningonsemantichighpush_patch to a route another LiveView renders, which raises
SC702Soundcheck.Check.Refactor.NavigateAcrossLiveSessionrefactoroffsemanticlowpush_navigate, <.link navigate>, and JS.navigate into another live_session, which reloads the page
SC703Soundcheck.Check.Warning.PatchWithoutHandleParamswarningonsemantichighpush_patch, <.link patch>, and JS.patch back to a LiveView that defines no handle_params/3, which raises
SC801Soundcheck.Check.Refactor.FormForChangesetrefactoroffsyntacticmediumForms built from a changeset assign instead of to_form/2
SC802Soundcheck.Check.Refactor.ChangeWithoutDebouncerefactoroffsyntacticmediumText inputs in phx-change forms without phx-debounce
SC803Soundcheck.Check.Warning.FormWithoutRecoverywarningoffsyntacticmediumphx-submit forms without an id or phx-change, whose input LiveView cannot restore after a reconnect
SC804Soundcheck.Check.Warning.UndefinedUploadwarningoffsyntacticmedium@uploads.name reads and consume_uploaded_entries and other upload calls on names the module never allows
SC901Soundcheck.Check.Design.ClickOnNonInteractiveElementdesignoffsyntactichighphx-click on non-interactive elements without role or keyboard support
SC1001Soundcheck.Check.Warning.UndefinedHookwarningonsyntactichighphx-hook names LiveSocket does not register
SC1002Soundcheck.Check.Design.UnusedHookdesignoffsyntacticmediumHooks registered with LiveSocket that no template uses; Credo reports these against the JavaScript file
SC1003Soundcheck.Check.Warning.HookWithoutIdwarningonsyntactichighHTML 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

OptionMeaning
--allRun every check, including opt-in checks
--only SC101,SC1002Run only the listed rule IDs
--strictInclude unverifiable findings
--format text|json|sarifOutput format; defaults to text
--output FILEWrite output to a file instead of standard output
--path GLOBElixir source glob; repeatable; defaults to lib/**/*.ex
--js-path DIRJavaScript source directory; repeatable; overrides configured js_paths
--config-file FILECredo configuration file; defaults to .credo.exs
--config-name NAMENamed Credo configuration; defaults to default
--no-compileUse the existing build without recompiling
--baseline FILEUse a specific baseline file
--no-baselineReport findings even if they are in the baseline
--write-baselineRecord 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:

ParameterDefaultMeaning
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.
strictfalseInclude 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.

CheckParamDefaultMeaning
UnhandledEvent, UnusedEventHandlerignore_events[]Event names to skip: binaries (exact) or regexes
EventPayloadMismatchmetadata_keys[]Keys added to click payloads by LiveSocket metadata callbacks
PossiblyUnsetAssignassume_set[:current_user, :current_scope, :flash, :live_action, :page_title]Keys the team's conventions guarantee before the first render
UnreadAssignignore_assigns[:page_title]Keys never reported
UndeclaredComponentAttrimplicit_assigns[:inner_block, :rest, :__changed__, :__given__]Assign keys never reported
UnguardedSubscribe, SubscribedWithoutHandleInfosubscribe_functions[~r/^Phoenix\.PubSub\.subscribe$/, ~r/\.subscribe$/]Subscribing functions, matched against Module.function with aliases expanded
ExpensiveCallInTemplateexpensive_modules, expensive_functions[~r/\.Repo$/, ~r/^Ecto\.Query$/, ~r/^(Req|Finch|HTTPoison|Tesla)$/], [{Enum, :sort}, ...]Calls that count as expensive in templates
ExpensiveMountWorkexpensive_modules[~r/\.Repo$/, ~r/^(Req|Finch|HTTPoison|Tesla)$/]Modules whose calls count as expensive in mount/3
BlockingCallInHandlerblocking_functions, blocking_modules[{Process, :sleep}, {:timer, :sleep}, {Task, :await}, ...], [~r/^(Req|Finch|HTTPoison|Tesla)$/]Calls that block a handler
UnscopedLookupByClientParamrepo_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
UnscopedLookupByClientParamlookup_functions[~r/\.get_\w+!?$/, ~r/\.fetch_\w+!?$/]Context lookup functions in project modules, matched against Module.function
LiveRouteWithoutAuthHookauth_hooks, public_paths:auto, []Hook modules that authenticate (:auto: names containing Auth, once any route uses one); route paths that need none
NavigateAcrossLiveSessionignore_sessions[]live_session names to skip as origin or target
ChangeWithoutDebounceinput_types["text", "search", "email", "textarea", "url", "tel"]Input types that count as text-like
ClickOnNonInteractiveElementnon_interactive_tags~w(div span li td tr img p section article)Tags that are not interactive by default
UndefinedHookexternal_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
DirectiveScope
disable-next-lineThe following line
disable-lineThe comment's own line
disable-fileThe 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 locationWhere Credo reports it
Inside a ~H sigilThe exact attribute in the Elixir file
In a .heex fileThe owning module's embed_templates call, or defmodule for a colocated LiveView template; the message includes the template location
A hook used by a templateThe 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.