Current section
Files
Jump to
Current section
Files
lib/mix/tasks/harness/init.ex
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-<timestamp>.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