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