lib/mix/tasks/lockstep.shrink.ex

defmodule Mix.Tasks.Lockstep.Shrink do
  @moduledoc """
  Reduce a failing Lockstep trace to a minimal schedule that still
  triggers the same bug.

  ## Usage

      mix lockstep.shrink --trace traces/foo.lockstep \\
                          --run "MyApp.RaceTest.lost_update_body" \\
                          --file test/examples/foo_test.exs

  Loads `--file` (so test modules become available), then drives
  `Lockstep.Shrink.shrink/3` against the trace. Saves a `.shrunk.lockstep`
  alongside the original and prints a one-line summary.

  Use `--verbose` for per-attempt log lines.
  """

  use Mix.Task

  @shortdoc "Shrink a saved Lockstep trace to a minimal repro"

  @impl true
  def run(args) do
    Mix.Task.run("compile")

    {opts, _, _} =
      OptionParser.parse(args,
        strict: [
          trace: :string,
          run: :string,
          file: :string,
          verbose: :boolean,
          max_attempts: :integer,
          iter_timeout: :integer
        ],
        aliases: [t: :trace, r: :run, f: :file, v: :verbose]
      )

    trace_path = Keyword.get(opts, :trace) || Mix.raise("--trace <path> is required")
    run_ref = Keyword.get(opts, :run) || Mix.raise("--run \"Mod.fn\" is required")
    file = Keyword.get(opts, :file)

    if file, do: Code.require_file(file)

    fun = resolve_function(run_ref)

    shrink_opts =
      []
      |> add_opt(opts, :verbose, false)
      |> add_opt(opts, :max_attempts, 500)
      |> add_opt(opts, :iter_timeout, 10_000)

    case Lockstep.Shrink.shrink(fun, trace_path, shrink_opts) do
      {:ok, info} ->
        Mix.shell().info("""

        Lockstep shrink succeeded:
          original schedule decisions: #{info.original_length}
          shrunk schedule decisions:   #{info.new_length}
          attempts:                    #{info.attempts}
          bug signature:               #{inspect(info.signature)}
          shrunk trace:                #{info.new_trace_path}

        Replay it with:
            mix lockstep.replay --trace #{info.new_trace_path} \\
                                --run "#{run_ref}" \\
                                --file #{file || "<your test file>"}
        """)

      {:error, :no_smaller_schedule_found} ->
        Mix.shell().info("""

        Lockstep shrink: no smaller schedule reproduces the same bug.
        The original trace is already minimal under the strict-replay
        shrink algorithm.
        """)

      {:error, :original_does_not_reproduce} ->
        Mix.raise("""

        Lockstep shrink: the original trace did not reproduce a bug
        under strict replay. Likely causes: the test_fun argument
        doesn't match the original test body, or the user code has
        nondeterminism that's making replay diverge.
        """)

      {:error, :original_diverges_under_strict_replay} ->
        Mix.raise("""

        Lockstep shrink: original trace diverges under strict replay.
        The user code is nondeterministic in something other than
        message ordering (raw RNG, ETS, system time, NIFs, real I/O).
        Lockstep can only shrink traces whose nondeterminism is
        entirely in the controlled schedule.
        """)

      {:error, reason} ->
        Mix.raise("Lockstep shrink failed: #{inspect(reason)}")
    end
  end

  # ============================================================

  defp add_opt(acc, opts, key, default) do
    [{key, Keyword.get(opts, key, default)} | acc]
  end

  defp resolve_function(string) do
    case String.split(string, ".") do
      parts when length(parts) >= 2 ->
        {fun_name, mod_parts} = List.pop_at(parts, -1)
        mod = Module.concat(mod_parts)

        unless Code.ensure_loaded?(mod) do
          Mix.raise("module #{inspect(mod)} is not loaded; pass --file to load it")
        end

        fun_atom = String.to_atom(fun_name)

        unless function_exported?(mod, fun_atom, 0) do
          Mix.raise(
            "function #{inspect(mod)}.#{fun_name}/0 is not defined (must be 0-arity and public)"
          )
        end

        fn -> apply(mod, fun_atom, []) end

      _ ->
        Mix.raise("--run must be \"Module.fun\" (e.g. \"MyApp.MyTest.race_body\")")
    end
  end
end