Current section
Files
Jump to
Current section
Files
lib/mix/tasks/dialyzer_json.ex
defmodule Mix.Tasks.Dialyzer.Json do
@shortdoc "Run dialyzer with JSON output"
@moduledoc """
Runs dialyzer and outputs warnings as JSON.
This task wraps dialyxir's PLT building but calls `:dialyzer.run/1` directly,
transforming raw warnings to structured JSON optimized for AI code editors
(Claude Code, Cursor, etc.).
## Quick Start
# Health check - see summary counts
mix dialyzer.json --quiet --summary-only | jq '.summary'
# Find real bugs (fix_hint: "code")
mix dialyzer.json --quiet | jq '.warnings[] | select(.fix_hint == "code")'
# Fix issues, then re-run to verify
mix dialyzer.json --quiet --summary-only
## When to Use This vs `mix dialyzer`
| Use Case | Command |
|----------|---------|
| Human reading warnings | `mix dialyzer` |
| AI parsing warnings | `mix dialyzer.json --quiet` |
| CI/CD pipelines | `mix dialyzer.json --quiet --output warnings.json` |
| Quick status check | `mix dialyzer.json --quiet --summary-only` |
## Options
* `--quiet` - Suppress non-JSON output (always use for parsing)
* `--summary-only` - Counts only, no individual warnings
* `--group-by-warning` - Group warnings by type
* `--group-by-file` - Group warnings by file path
* `--filter-type TYPE` - Only show specific types (repeatable, OR logic)
* `--compact` - JSONL output (one warning per line)
* `--output FILE` - Write to file instead of stdout
* `--ignore-exit-status` - Don't fail on warnings (exit 0)
## Output Format
Full output includes metadata and summary:
{
"metadata": {
"schema_version": "1.0",
"dialyzer_version": "5.4",
"elixir_version": "1.19.4",
"otp_version": "28",
"run_at": "2026-02-02T07:00:03Z"
},
"warnings": [...],
"summary": {
"total": 5,
"by_type": {"no_return": 2, "call": 3},
"by_fix_hint": {"code": 4, "spec": 1}
}
}
Each warning object contains:
{
"file": "lib/foo.ex",
"line": 42,
"column": 5,
"function": "bar/2",
"module": "Foo",
"warning_type": "no_return",
"message": "Function has no local return",
"raw_message": "Function bar/2 has no local return.",
"fix_hint": "code"
}
With `--group-by-warning`, warnings are grouped by type:
{"warnings": {"no_return": [...], "call": [...]}, ...}
With `--group-by-file`, warnings are grouped by file:
{"groups": [{"file": "lib/foo.ex", "count": 3, "warnings": [...]}], ...}
## Fix Hint Guide
The `fix_hint` field tells you what action to take:
| Hint | Meaning | Action |
|------|---------|--------|
| `"code"` | Likely a real bug | **Fix immediately** - unreachable code, impossible patterns |
| `"spec"` | Typespec mismatch | Fix the `@spec` - code is probably correct |
| `"pattern"` | Common safe-to-ignore | Often intentional - third-party behaviours |
| `"unknown"` | Unrecognized warning | Investigate manually |
**Priority order:** `code` > `spec` > `pattern`
## Common jq Recipes
# Find all code bugs
mix dialyzer.json --quiet | jq '.warnings[] | select(.fix_hint == "code")'
# Most common warning types
mix dialyzer.json --quiet | jq '.summary.by_type | to_entries | sort_by(-.value)'
# Warnings in a specific file
mix dialyzer.json --quiet | jq '.warnings[] | select(.file == "lib/my_module.ex")'
# Count warnings per file
mix dialyzer.json --quiet | jq '.warnings | group_by(.file) | map({file: .[0].file, count: length})'
## Exit Codes
| Code | Meaning |
|------|---------|
| `0` | No warnings found |
| `2` | Warnings found |
| Other | Error occurred |
Use `--ignore-exit-status` to always exit 0 (useful for CI that shouldn't fail on warnings).
## Tips
1. **Always use `--quiet`** for parsing - suppresses dialyzer progress output
2. **Pipe to jq** for filtering and formatting
3. **Use `--compact`** for large warning sets or streaming
4. **Use `--filter-type`** to focus on specific warning categories
5. **Check `fix_hint`** before fixing - `"pattern"` warnings are often intentional
"""
use Mix.Task
alias DialyzerJson.WarningEncoder
# Exit code when dialyzer finds warnings (standard convention: 2 = warnings found)
@exit_code_warnings_found 2
@impl Mix.Task
@spec run([String.t()]) :: :ok | no_return()
def run(args) do
{opts, _remaining} = extract_opts(args)
# Suppress output immediately when --quiet is used
# Must happen BEFORE compile/dialyzer tasks run
if opts[:quiet] do
Mix.shell(Mix.Shell.Quiet)
:logger.remove_handler(:default)
Application.put_env(:logger, :level, :error)
end
# When --quiet without --output, buffer to temp file for clean piping
{opts, temp_output_path} = maybe_use_temp_output(opts)
# Ensure dialyxir's PLT is ready
ensure_plt!()
# Run dialyzer and get warnings
warnings = run_dialyzer()
# Encode to JSON
encoded = encode_output(warnings, opts)
# Output
output_json(encoded, opts)
# If we used temp buffering, output JSON now (after all other stdout pollution)
if temp_output_path do
output_buffered_json(temp_output_path)
end
# Exit with appropriate code
cond do
warnings == [] or opts[:ignore_exit_status] ->
:ok
opts[:quiet] ->
# In quiet mode, exit silently - the exit code indicates failure
System.halt(@exit_code_warnings_found)
true ->
Mix.raise("Dialyzer found #{length(warnings)} warning(s)")
end
end
@doc false
# Parses command-line arguments into options keyword list
@spec extract_opts([String.t()]) :: {keyword(), [String.t()]}
def extract_opts(args) do
{opts, remaining} = do_extract_opts(args, [], [])
{merge_list_opts(opts), remaining}
end
defp do_extract_opts([], opts, remaining) do
{Enum.reverse(opts), Enum.reverse(remaining)}
end
defp do_extract_opts(["--quiet" | rest], opts, remaining) do
do_extract_opts(rest, [{:quiet, true} | opts], remaining)
end
defp do_extract_opts(["--summary-only" | rest], opts, remaining) do
do_extract_opts(rest, [{:summary_only, true} | opts], remaining)
end
defp do_extract_opts(["--group-by-warning" | rest], opts, remaining) do
do_extract_opts(rest, [{:group_by_warning, true} | opts], remaining)
end
defp do_extract_opts(["--group-by-file" | rest], opts, remaining) do
do_extract_opts(rest, [{:group_by_file, true} | opts], remaining)
end
defp do_extract_opts(["--output", path | rest], opts, remaining) do
do_extract_opts(rest, [{:output, path} | opts], remaining)
end
defp do_extract_opts(["--ignore-exit-status" | rest], opts, remaining) do
do_extract_opts(rest, [{:ignore_exit_status, true} | opts], remaining)
end
defp do_extract_opts(["--compact" | rest], opts, remaining) do
do_extract_opts(rest, [{:compact, true} | opts], remaining)
end
defp do_extract_opts(["--filter-type", type | rest], opts, remaining) do
do_extract_opts(rest, [{:filter_type, type} | opts], remaining)
end
defp do_extract_opts([arg | rest], opts, remaining) do
do_extract_opts(rest, opts, [arg | remaining])
end
@doc false
# Collects repeated CLI options (like multiple --filter-type flags) into a single list value.
# E.g., [{:filter_type, "a"}, {:filter_type, "b"}] -> [{:filter_type, ["a", "b"]}]
@spec merge_list_opts(keyword()) :: keyword()
defp merge_list_opts(opts) do
{filter_types, other_opts} = Keyword.pop_values(opts, :filter_type)
if filter_types == [] do
other_opts
else
[{:filter_type, filter_types} | other_opts]
end
end
@doc false
# Ensures dialyxir's PLT is built and up to date
@spec ensure_plt!() :: :ok
defp ensure_plt! do
Mix.Task.run("compile")
Mix.Task.run("dialyzer", ["--plt"])
:ok
end
@doc false
# Runs dialyzer and returns the list of warnings
@spec run_dialyzer() :: [WarningEncoder.warning()]
defp run_dialyzer do
# Runtime calls to avoid compile-time warnings when used as a path dependency
# credo:disable-for-lines:2 Credo.Check.Refactor.Apply
plt_file = apply(Dialyxir.Project, :plt_file, [])
files = apply(Dialyxir.Project, :dialyzer_files, [])
args = [
check_plt: false,
init_plt: String.to_charlist(plt_file),
files: files,
warnings: [:unknown]
]
try do
:dialyzer.run(args)
catch
{:dialyzer_error, msg} ->
Mix.shell().error("Dialyzer error: #{msg}")
[]
end
end
@doc false
# Transforms raw warnings into JSON-ready output structure based on options
@spec encode_output([WarningEncoder.warning()], keyword()) :: map()
def encode_output(warnings, opts) do
encoded_warnings =
warnings
|> WarningEncoder.encode_warnings()
|> filter_warnings(opts[:filter_type])
summary = %{
total: length(encoded_warnings),
by_type: count_by_type(encoded_warnings),
by_fix_hint: count_by_fix_hint(encoded_warnings)
}
metadata = build_metadata()
if opts[:summary_only] do
%{metadata: metadata, summary: summary}
else
cond do
opts[:group_by_file] ->
%{
metadata: metadata,
groups: group_by_file(encoded_warnings),
summary: summary
}
opts[:group_by_warning] ->
%{
metadata: metadata,
warnings: group_by_type(encoded_warnings),
summary: summary
}
true ->
%{
metadata: metadata,
warnings: encoded_warnings,
summary: summary
}
end
end
end
@doc false
# Filters warnings to only include specified types
@spec filter_warnings([WarningEncoder.encoded_warning()], [String.t()] | nil) ::
[WarningEncoder.encoded_warning()]
def filter_warnings(warnings, nil), do: warnings
def filter_warnings(warnings, []), do: warnings
def filter_warnings(warnings, types) when is_list(types) do
Enum.filter(warnings, &(&1.warning_type in types))
end
@doc false
# Counts warnings grouped by warning type
@spec count_by_type([WarningEncoder.encoded_warning()]) :: %{String.t() => non_neg_integer()}
def count_by_type(warnings) do
Enum.reduce(warnings, %{}, fn warning, acc ->
Map.update(acc, warning.warning_type, 1, &(&1 + 1))
end)
end
@doc false
# Counts warnings grouped by fix hint category
@spec count_by_fix_hint([WarningEncoder.encoded_warning()]) :: %{
String.t() => non_neg_integer()
}
def count_by_fix_hint(warnings) do
Enum.reduce(warnings, %{}, fn warning, acc ->
Map.update(acc, warning.fix_hint, 1, &(&1 + 1))
end)
end
@doc false
# Groups warnings by warning type into a map
@spec group_by_type([WarningEncoder.encoded_warning()]) :: %{
String.t() => [WarningEncoder.encoded_warning()]
}
def group_by_type(warnings) do
Enum.group_by(warnings, & &1.warning_type)
end
@doc false
# Groups warnings by file path into an array of objects with file, count, and warnings fields
@spec group_by_file([WarningEncoder.encoded_warning()]) :: [
%{
file: String.t(),
count: non_neg_integer(),
warnings: [WarningEncoder.encoded_warning()]
}
]
def group_by_file(warnings) do
warnings
|> Enum.group_by(& &1.file)
|> Enum.map(fn {file, file_warnings} ->
%{file: file, count: length(file_warnings), warnings: file_warnings}
end)
|> Enum.sort_by(& &1.file)
end
@doc false
# Builds metadata with version info and timestamp
@spec build_metadata() :: map()
def build_metadata do
%{
schema_version: "1.0",
dialyzer_version: to_string(Application.spec(:dialyzer, :vsn)),
elixir_version: System.version(),
otp_version: to_string(:erlang.system_info(:otp_release)),
run_at: DateTime.utc_now() |> DateTime.to_iso8601()
}
end
@doc false
# Outputs JSON to stdout or file based on options
@spec output_json(map(), keyword()) :: :ok
defp output_json(data, opts) do
output =
if opts[:compact] do
build_compact_output(data)
else
Jason.encode!(data, pretty: true)
end
case opts[:output] do
nil -> IO.puts(output)
path -> File.write!(path, output <> "\n")
end
:ok
end
@doc false
# Builds JSONL output (one JSON object per line) for streaming/piping.
# Each warning becomes a single JSON line, with metadata and summary as the final line.
@spec build_compact_output(map()) :: String.t()
def build_compact_output(%{metadata: metadata, summary: summary} = data) do
warning_lines = extract_warning_lines(data)
summary_line = Jason.encode!(%{metadata: metadata, summary: summary})
(warning_lines ++ [summary_line])
|> Enum.join("\n")
end
@doc false
# Extracts individual warnings as JSON strings for JSONL output.
# Handles flat lists, grouped maps (from --group-by-warning), and file groups (from --group-by-file).
@spec extract_warning_lines(map()) :: [String.t()]
defp extract_warning_lines(%{warnings: warnings}) when is_list(warnings) do
Enum.map(warnings, &Jason.encode!/1)
end
defp extract_warning_lines(%{warnings: warnings}) when is_map(warnings) do
# Flatten grouped warnings back to individual lines
warnings
|> Map.values()
|> List.flatten()
|> Enum.map(&Jason.encode!/1)
end
defp extract_warning_lines(%{groups: groups}) when is_list(groups) do
# Flatten file groups back to individual warning lines
groups
|> Enum.flat_map(& &1.warnings)
|> Enum.map(&Jason.encode!/1)
end
defp extract_warning_lines(_no_warnings) do
# --summary-only mode, no warnings or groups key
[]
end
@doc false
# When --quiet is used without explicit --output, auto-buffer to temp file.
# This ensures stdout pollution doesn't corrupt JSON when piping.
@spec maybe_use_temp_output(keyword()) :: {keyword(), String.t() | nil}
defp maybe_use_temp_output(opts) do
quiet? = Keyword.get(opts, :quiet, false)
has_output? = Keyword.has_key?(opts, :output)
if quiet? and not has_output? do
temp_path =
Path.join(System.tmp_dir!(), "dialyzer_json_#{System.unique_integer([:positive])}.json")
{Keyword.put(opts, :output, temp_path), temp_path}
else
{opts, nil}
end
end
@doc false
# Outputs buffered JSON from temp file and cleans up.
@spec output_buffered_json(String.t()) :: :ok
defp output_buffered_json(path) do
case File.read(path) do
{:ok, content} ->
IO.write(content)
File.rm(path)
:ok
{:error, _} ->
# File might not exist if dialyzer crashed early
:ok
end
end
end