defmodule Mix.Tasks.Harness.Init do @shortdoc "Full-codebase audit via dialyzer + reach" @moduledoc """ #{@shortdoc} Runs `mix dialyzer --format short` and `Reach.Project` taint / dead-code analysis against the host project, then writes a Markdown audit report to `.harness/audit-.md` plus a `latest.md` copy and a `latest.json` machine-readable dump. ## Examples mix harness.init mix harness.init --skip dialyzer mix harness.init --quick ## Options * `--root DIR` — project root to analyze (default: cwd) * `--skip LIST` — comma-separated runners to skip (`dialyzer`, `reach`) * `--quick` — skip the `mix dialyzer --plt` rebuild step Always exits 0 — the report is the deliverable. CI gating with thresholds is intentionally a separate task (`harness.init.ci`, planned for v0.2). """ use Mix.Task alias ExHarness.Audit alias ExHarness.DialyzerRunner alias ExHarness.ReachRunner @impl Mix.Task def run(argv) do {opts, _, _} = OptionParser.parse(argv, strict: [root: :string, skip: :string, quick: :boolean] ) root = Path.expand(opts[:root] || File.cwd!()) skip = (opts[:skip] || "") |> String.split(",", trim: true) |> Enum.map(&String.trim/1) |> MapSet.new() File.mkdir_p!(Path.join(root, ".harness")) Mix.shell().info("ex_harness: dialyzer + reach audit on #{root}") dialyzer = if MapSet.member?(skip, "dialyzer") do Mix.shell().info(" - dialyzer skipped") nil else Mix.shell().info(" - dialyzer running…") DialyzerRunner.run(root, quick: opts[:quick] == true) end reach = if MapSet.member?(skip, "reach") do Mix.shell().info(" - reach skipped") nil else Mix.shell().info(" - reach running…") ReachRunner.run(root) end metadata = build_metadata(root) md = Audit.render(metadata, dialyzer, reach) json = Audit.to_json(metadata, dialyzer, reach) stamp = stamp(metadata.timestamp) timestamped = Path.join(root, ".harness/audit-#{stamp}.md") latest_md = Path.join(root, ".harness/latest.md") latest_json = Path.join(root, ".harness/latest.json") File.write!(timestamped, md) File.write!(latest_md, md) File.write!(latest_json, encode_json(json)) Mix.shell().info("\nReport: #{timestamped}") Mix.shell().info(" #{latest_md}") Mix.shell().info(" #{latest_json}") print_summary(dialyzer, reach) :ok end defp build_metadata(root) do %{ root: root, timestamp: DateTime.utc_now(), elixir: System.version(), otp: :erlang.system_info(:otp_release) |> List.to_string(), lock_digest: lock_digest(root) } end defp lock_digest(root) do path = Path.join(root, "mix.lock") case File.read(path) do {:ok, contents} -> :crypto.hash(:sha256, contents) |> Base.encode16(case: :lower) |> binary_part(0, 12) _ -> nil end end defp stamp(%DateTime{} = dt) do dt |> DateTime.to_iso8601(:basic) |> String.replace(~r/[^0-9TZ]/, "") end defp encode_json(map) do if Code.ensure_loaded?(Jason) do Jason.encode!(map, pretty: true) else inspect(map, pretty: true, limit: :infinity, printable_limit: :infinity) end end defp print_summary(dialyzer, reach) do Mix.shell().info("") Mix.shell().info("Summary:") if dialyzer do Mix.shell().info(" dialyzer findings: #{length(dialyzer.findings)}") end if reach do Mix.shell().info( " reach: #{reach.scope.modules} modules, " <> "#{length(reach.taints)} taints, " <> "#{length(reach.dead_code)} dead-code candidates" ) end end end