Packages

One-command installer for an Elixir static-analysis suite (credo, dialyxir, ex_dna, ex_slop, boundary, reach) plus a `mix harness.init` whole-codebase audit.

Current section

Files

Jump to
ex_harness lib mix tasks harness install.ex
Raw

lib/mix/tasks/harness/install.ex

defmodule Mix.Tasks.Harness.Install do
@shortdoc "Install ex_harness static-analysis suite"
@moduledoc """
#{@shortdoc}
Adds the static-analysis suite to a host project (deps + mix.exs +
precommit alias + .gitignore + .credo.exs) using Igniter.
## Examples
mix harness.install
mix harness.install --skip makeup,makeup_elixir
## Options
* `--skip LIST` — comma-separated dep names to skip
(e.g. `credo`, `dialyxir`, `ex_dna`, `ex_slop`, `boundary`,
`reach`, `makeup`, `makeup_elixir`)
Re-running this task is idempotent: deps already declared are left
untouched, and the precommit alias only gains missing entries.
"""
use Igniter.Mix.Task
@example "mix harness.install"
@deps [
{:credo, "~> 1.7", only: [:dev, :test], runtime: false},
{:dialyxir, "~> 1.4", only: [:dev, :test], runtime: false},
{:ex_dna, "~> 1.3", only: [:dev, :test], runtime: false},
{:ex_slop, "~> 0.2", only: [:dev, :test], runtime: false},
{:boundary, "~> 0.10", runtime: false},
{:reach, "~> 1.3"},
{:makeup, "~> 1.0", only: [:dev, :test]},
{:makeup_elixir, "~> 1.0", only: [:dev, :test]}
]
@impl Igniter.Mix.Task
def info(_argv, _parent) do
%Igniter.Mix.Task.Info{
example: @example,
schema: [skip: :string],
defaults: [skip: ""]
}
end
@impl Igniter.Mix.Task
def igniter(igniter) do
skip = parse_skip(igniter.args.options[:skip])
igniter
|> add_deps(skip)
|> add_compilers(skip)
|> add_dialyzer_config(skip)
|> add_precommit_alias(skip)
|> add_gitignore_entries(skip)
|> add_credo_config(skip)
|> add_plts_gitkeep(skip)
|> add_notice(skip)
end
# ── helpers ────────────────────────────────────────────────────────
defp parse_skip(nil), do: MapSet.new()
defp parse_skip(str) when is_binary(str) do
str
|> String.split(",", trim: true)
|> Enum.map(&String.trim/1)
|> MapSet.new()
end
defp add_deps(igniter, skip) do
Enum.reduce(@deps, igniter, fn dep, acc ->
name = dep |> elem(0) |> Atom.to_string()
if MapSet.member?(skip, name) do
acc
else
Igniter.Project.Deps.add_dep(acc, dep, on_exists: :skip)
end
end)
end
# The remaining helpers are implemented in subsequent phases.
# Keeping them as no-ops here lets `mix harness.install` already
# add deps and remain idempotent while we iterate.
defp add_compilers(igniter, skip) do
if MapSet.member?(skip, "boundary") do
igniter
else
Igniter.Project.MixProject.update(igniter, :project, [:compilers], fn
nil ->
{:ok, {:code, quote(do: Mix.compilers() ++ [:boundary])}}
zipper ->
if has_atom?(zipper.node, :boundary) do
{:ok, zipper}
else
case Igniter.Code.List.prepend_new_to_list(zipper, :boundary) do
{:ok, z} ->
{:ok, z}
:error ->
{:warning,
"Could not auto-add `:boundary` to `compilers:` " <>
"(unsupported expression). Add `:boundary` manually."}
end
end
end)
end
end
defp has_atom?(ast, atom) do
ast
|> Macro.prewalk(false, fn
^atom, _acc -> {atom, true}
node, acc -> {node, acc}
end)
|> elem(1)
end
@dialyzer_config [
plt_local_path: "priv/plts/dialyzer.plt",
plt_core_path: "priv/plts/dialyzer-core.plt",
plt_add_apps: [:ex_unit, :mix],
flags: [:error_handling, :unknown, :no_opaque]
]
@dialyzer_marker "priv/plts/dialyzer.plt"
defp add_dialyzer_config(igniter, skip) do
if MapSet.member?(skip, "dialyxir") do
igniter
else
config = @dialyzer_config
Igniter.Project.MixProject.update(igniter, :project, [:dialyzer], fn
nil ->
{:ok, {:code, Macro.escape(config)}}
zipper ->
if ours?(zipper.node) do
{:ok, zipper}
else
{:warning,
"Existing `:dialyzer` config detected in mix.exs — leaving it untouched. " <>
"Recommended ex_harness defaults: #{inspect(config)}"}
end
end)
end
end
defp ours?(node) do
node
|> Macro.to_string()
|> String.contains?(@dialyzer_marker)
end
defp add_precommit_alias(igniter, skip) do
cmds =
[
"compile --warnings-as-errors",
"deps.unlock --unused",
"format"
] ++
skip_filter(skip, "credo", ["credo --strict"]) ++
skip_filter(skip, "ex_dna", ["ex_dna"]) ++
skip_filter(skip, "dialyxir", ["dialyzer --format short"]) ++
["test"]
igniter
|> Igniter.Project.TaskAliases.add_alias(:precommit, cmds, if_exists: :append)
|> Igniter.Project.MixProject.update(:cli, [:preferred_envs, :precommit], fn _ ->
{:ok, {:code, :test}}
end)
end
defp skip_filter(skip, name, cmds) do
if MapSet.member?(skip, name), do: [], else: cmds
end
defp add_gitignore_entries(igniter, skip) do
entries =
skip_filter(skip, "dialyxir", ["/priv/plts/*.plt", "/priv/plts/*.plt.hash"]) ++
skip_filter(skip, "reach", ["/.reach/"]) ++
["/.harness/"]
Igniter.create_or_update_file(igniter, ".gitignore", build_gitignore(entries), fn source ->
content = Rewrite.Source.get(source, :content) || ""
missing = Enum.reject(entries, &gitignore_has?(content, &1))
if missing == [] do
source
else
addition = "\n# Added by ex_harness\n" <> Enum.join(missing, "\n") <> "\n"
Rewrite.Source.update(source, :content, content <> addition)
end
end)
end
defp build_gitignore(entries) do
"# Generated by ex_harness\n" <> Enum.join(entries, "\n") <> "\n"
end
defp gitignore_has?(content, entry) do
content
|> String.split("\n")
|> Enum.map(&String.trim/1)
|> Enum.member?(String.trim(entry))
end
defp add_credo_config(igniter, skip) do
if MapSet.member?(skip, "credo") do
igniter
else
Igniter.create_new_file(igniter, ".credo.exs", credo_template(skip), on_exists: :skip)
end
end
defp credo_plugins_literal(skip) do
entries = skip_filter(skip, "ex_slop", ["{ExSlop, []}"])
"[" <> Enum.join(entries, ", ") <> "]"
end
defp add_plts_gitkeep(igniter, skip) do
if MapSet.member?(skip, "dialyxir") do
igniter
else
Igniter.create_new_file(
igniter,
"priv/plts/.gitkeep",
"# Keeps priv/plts/ in source control for ex_harness dialyzer.\n",
on_exists: :skip
)
end
end
defp add_notice(igniter, skip) do
plt_step =
if MapSet.member?(skip, "dialyxir"),
do: "",
else: " mix dialyzer --plt # one-time PLT build (~30-60s)\n"
boundary_step =
if MapSet.member?(skip, "boundary") do
""
else
" Add `use Boundary` to each top-level context module — see\n" <>
" https://hexdocs.pm/boundary\n"
end
ex_slop_step =
if MapSet.member?(skip, "ex_slop") do
""
else
" If `.credo.exs` already existed, add `{ExSlop, []}` to its `plugins:` list.\n"
end
Igniter.add_notice(igniter, """
ex_harness installed. Next steps:
mix deps.get
#{plt_step}#{boundary_step}#{ex_slop_step} mix harness.init # whole-codebase audit
mix precommit # full lint + test sweep
""")
end
@credo_template_pre ~S'''
# Default .credo.exs generated by ex_harness.
# Adjust as needed; this file is not regenerated on subsequent runs.
%{
configs: [
%{
name: "default",
files: %{
included: ["lib/", "test/"],
excluded: []
},
plugins: __EX_HARNESS_PLUGINS__,
requires: [],
strict: true,
parse_timeout: 5000,
color: true,
checks: %{
enabled: [
{Credo.Check.Consistency.ExceptionNames, []},
{Credo.Check.Consistency.LineEndings, []},
{Credo.Check.Consistency.ParameterPatternMatching, []},
{Credo.Check.Consistency.SpaceAroundOperators, []},
{Credo.Check.Consistency.SpaceInParentheses, []},
{Credo.Check.Consistency.TabsOrSpaces, []},
{Credo.Check.Design.AliasUsage, [if_nested_deeper_than: 2]},
{Credo.Check.Readability.AliasOrder, []},
{Credo.Check.Readability.FunctionNames, []},
{Credo.Check.Readability.LargeNumbers, []},
{Credo.Check.Readability.MaxLineLength, [max_length: 120]},
{Credo.Check.Readability.ModuleAttributeNames, []},
{Credo.Check.Readability.ModuleDoc, []},
{Credo.Check.Readability.ModuleNames, []},
{Credo.Check.Readability.ParenthesesInCondition, []},
{Credo.Check.Readability.PredicateFunctionNames, []},
{Credo.Check.Readability.PreferImplicitTry, []},
{Credo.Check.Readability.RedundantBlankLines, []},
{Credo.Check.Readability.Semicolons, []},
{Credo.Check.Readability.SpaceAfterCommas, []},
{Credo.Check.Readability.StringSigils, []},
{Credo.Check.Readability.TrailingBlankLine, []},
{Credo.Check.Readability.TrailingWhiteSpace, []},
{Credo.Check.Readability.VariableNames, []},
{Credo.Check.Refactor.CondStatements, []},
{Credo.Check.Refactor.CyclomaticComplexity, []},
{Credo.Check.Refactor.FunctionArity, []},
{Credo.Check.Refactor.LongQuoteBlocks, []},
{Credo.Check.Refactor.MatchInCondition, []},
{Credo.Check.Refactor.NegatedConditionsInUnless, []},
{Credo.Check.Refactor.NegatedConditionsWithElse, []},
{Credo.Check.Refactor.Nesting, []},
{Credo.Check.Refactor.UnlessWithElse, []},
{Credo.Check.Warning.BoolOperationOnSameValues, []},
{Credo.Check.Warning.ExpensiveEmptyEnumCheck, []},
{Credo.Check.Warning.IExPry, []},
{Credo.Check.Warning.IoInspect, []},
{Credo.Check.Warning.OperationOnSameValues, []},
{Credo.Check.Warning.OperationWithConstantResult, []},
{Credo.Check.Warning.UnusedEnumOperation, []},
{Credo.Check.Warning.UnusedFileOperation, []},
{Credo.Check.Warning.UnusedKeywordOperation, []},
{Credo.Check.Warning.UnusedListOperation, []},
{Credo.Check.Warning.UnusedPathOperation, []},
{Credo.Check.Warning.UnusedRegexOperation, []},
{Credo.Check.Warning.UnusedStringOperation, []},
{Credo.Check.Warning.UnusedTupleOperation, []}
]
}
}
]
}
'''
defp credo_template(skip) do
String.replace(@credo_template_pre, "__EX_HARNESS_PLUGINS__", credo_plugins_literal(skip))
end
end