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 dialyzer task and transforms the output to JSON,
optimized for AI code editors (Claude Code, Cursor, etc.).
## Usage
mix dialyzer.json
mix dialyzer.json --quiet
## Options
* `--quiet` - Suppress non-JSON output for clean piping
* `--summary-only` - Output only counts by warning type, no details
* `--group-by-warning` - Group warnings by type in output
* `--group-by-file` - Group warnings by file path in output
* `--output FILE` - Write JSON to file instead of stdout
* `--compact` - Output as JSONL (one JSON object per line)
* `--filter-type TYPE` - Only include warnings of TYPE (can be repeated)
## Output Format
The output is a JSON object with:
{
"warnings": [...], // Array of warning objects
"summary": { // Summary statistics
"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"
}
The `fix_hint` field indicates the likely fix category:
- `"spec"` - Likely needs typespec fix
- `"code"` - Likely a real bug
- `"pattern"` - Common safe-to-ignore pattern
"""
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