Current section

Files

Jump to
al_check lib check.ex
Raw

lib/check.ex

defmodule CheckEscript do
@moduledoc """
Runs all code quality checks in parallel.
## Usage
check # Run all checks
check --help/-h # Show this help message
check --fast # Run only fast checks (format, compile, credo)
check --only format,test # Run only format and test
check --only credo # Run credo and credo_strict
check --partitions 2 # Run tests with 2 partitions (default: 3)
check --dir test/dir # Run tests only from specific directory
check --fix # Apply fixes from stored credo output
check --failed # Re-run only failed tests from previous run
check --watch # Monitor test partition files in real-time
check --test-args "--exclude slow" # Replace default --warnings-as-errors with custom args
check --repeat 10 # Run tests with --repeat-until-failure 10 (default: 100)
## Available checks
- format - mix format --check-formatted
- compile - mix compile --warnings-as-errors
- compile_test - MIX_ENV=test mix compile --warnings-as-errors
- dialyzer - mix dialyzer
- credo - mix credo --all
- credo_strict - mix credo --strict --only readability --all
- test - mix test (runs in partitions for parallel execution)
## Fast checks
The --fast flag runs only: format, compile, compile_test, credo, credo_strict
(skips: dialyzer, test)
## Test partitioning
Tests run in parallel partitions (default: 5). Each partition uses its own database.
Use --partitions N to customize the number of partitions based on your CPU cores.
## Failed test workflow
When tests fail, failed test locations are automatically saved to check/failed_tests.txt:
check --only test # Run tests and save failures
cat .check/failed_tests.txt # View failed tests
check --failed # Re-run only the failed tests
## Auto-fix workflow
Format and credo failures are tracked in .check/ directory for later use with --fix:
check --only format,credo # Run checks and store failures
check --fix # Apply fixes from stored failures
The --fix command will:
- Check if format failed previously (.check/.format_failed marker) and run mix format
- Apply credo fixes from stored output files (.check/credo.txt, .check/credo_strict.txt)
## Configuration file
Create a `.check.json` in your project root to customize behavior.
Override the path via the `CHECK_CONFIG` environment variable.
{
"fast": ["format", "compile", "compile_test", "credo"],
"partitions": 3,
"max_concurrency": 10,
"test_args": "--warnings-as-errors",
"default_repeat": 100,
"coverage": "native",
"checks": {
"format": {"name": "Formatting", "run": "mix format --check-formatted"},
"sobelow": {"name": "Security", "run": "mix sobelow --config"}
}
}
Name defaults to a capitalized version of the key (e.g. "compile_test" → "Compile Test").
Coverage modes: `false` (default), `"native"` (built-in --cover), or `"coveralls"` (excoveralls).
Both modes automatically merge partition coverage into a single report after all partitions complete.
All fields are optional. CLI flags override config values.
When `checks` is provided, it replaces all built-in checks (test partitions are always added).
"""
@default_checks %{
"format" => %{"name" => "Formatting", "run" => "mix format --check-formatted"},
"compile" => %{"name" => "Compile", "run" => "mix compile --warnings-as-errors"},
"compile_test" => %{
"name" => "Compile (test)",
"run" => "MIX_ENV=test mix compile --warnings-as-errors"
},
"dialyzer" => %{"name" => "Dialyzer", "run" => "mix dialyzer"},
"credo" => %{"name" => "Credo", "run" => "mix credo --all"},
"credo_strict" => %{
"name" => "Credo Strict",
"run" => "mix credo --strict --only readability --all"
}
}
@default_fast ["format", "compile", "compile_test", "credo", "credo_strict"]
@spec main([String.t()]) :: :ok
def main(args) do
{opts, mock_mode, fix_mode, invalid} = parse_args(args)
config = load_config()
repeat = resolve_repeat(opts[:repeat], invalid, config)
opts = Keyword.put(opts, :repeat, repeat)
cond do
# if --help flag, print help and exit
opts[:help] ->
print_help()
# if --watch flag, monitor test partition files
opts[:watch] ->
watch_partition_files()
# if --failed flag, run failed tests from previous run
opts[:failed] ->
run_failed_tests(opts[:repeat])
# if --fix flag, read stored outputs and apply fixes
fix_mode ->
apply_fixes_from_stored_output()
# normal check flow
true ->
partitions = opts[:partitions] || config["partitions"] || 3
max_concurrency = config["max_concurrency"] || 10
test_dir = opts[:dir]
test_args = opts[:test_args] || config["test_args"]
repeat = opts[:repeat]
coverage = parse_coverage(config["coverage"])
partitions = if mock_mode, do: partitions, else: cap_partitions(partitions, test_dir)
all_tasks =
define_tasks(mock_mode, partitions, test_dir, test_args, repeat, config, coverage)
tasks = select_tasks(all_tasks, opts, partitions, config)
if has_test_tasks?(tasks) do
save_test_args(test_args)
Path.wildcard(".check/test_partition_*.txt") |> Enum.each(&File.rm/1)
end
test_cmd = build_test_cmd(test_dir, test_args, repeat, partitions, coverage)
{results, total_seconds} = run_checks(tasks, repeat, test_cmd, max_concurrency)
print_summary(results, total_seconds, tasks, coverage)
end
end
defp load_config do
config_path =
case System.get_env("CHECK_CONFIG") do
nil -> Path.join(File.cwd!(), ".check.json")
"" -> Path.join(File.cwd!(), ".check.json")
path -> path
end
if File.exists?(config_path) do
case config_path |> File.read!() |> Jason.decode() do
{:ok, config} when is_map(config) ->
config
{:ok, _} ->
IO.puts(:stderr, "Warning: #{config_path} must contain a JSON object, ignoring config")
%{}
{:error, reason} ->
IO.puts(:stderr, "Warning: Failed to parse #{config_path}: #{inspect(reason)}")
%{}
end
else
%{}
end
end
defp parse_args(args) do
# Split on --test-args: everything after it is passed verbatim to mix test
{check_args, test_args} = split_test_args(args)
{opts, remaining_args, invalid} =
OptionParser.parse(check_args,
strict: [
only: :string,
fix: :boolean,
fast: :boolean,
partitions: :integer,
failed: :boolean,
dir: :string,
watch: :boolean,
repeat: :integer,
help: :boolean
],
aliases: [h: :help]
)
opts = if test_args, do: Keyword.put(opts, :test_args, test_args), else: opts
mock_mode = "mock" in remaining_args
fix_mode = opts[:fix] || false
{opts, mock_mode, fix_mode, invalid}
end
@check_flags ~w(--only --fix --fast --partitions --failed --dir --watch --repeat --help -h)
# Splits args on "--test-args" — collects args until the next known check flag
defp split_test_args(args) do
case Enum.split_while(args, &(&1 != "--test-args")) do
{before, ["--test-args" | rest]} ->
{test_args, trailing} = Enum.split_while(rest, &(&1 not in @check_flags))
{before ++ trailing, Enum.join(test_args, " ")}
{all, []} ->
{all, nil}
end
end
defp resolve_repeat(repeat, _invalid, _config) when is_integer(repeat), do: repeat
defp resolve_repeat(nil, invalid, config) do
default = config["default_repeat"] || 100
invalid_switches = Enum.map(invalid, fn {switch, _} -> switch end)
if "--repeat" in invalid_switches, do: default, else: nil
end
defp print_help do
@moduledoc
|> String.split("## ")
|> Enum.find(&String.starts_with?(&1, "Usage"))
|> then(&IO.puts("## " <> &1))
end
defp cap_partitions(partitions, test_dir) do
test_file_count =
Path.wildcard("#{test_dir || "test"}/**/*_test.exs") |> length()
cond do
test_file_count == 0 ->
IO.puts([
IO.ANSI.format([:yellow, "No test files found in #{test_dir || "test"}/"])
])
0
test_file_count < partitions ->
IO.puts([
IO.ANSI.format([
:yellow,
"Not enough test files for #{partitions} partitions (found #{test_file_count}), using #{test_file_count}"
])
])
test_file_count
true ->
partitions
end
end
defp define_tasks(mock_mode, partitions, test_dir, test_args, repeat, config, coverage) do
base_tasks =
if mock_mode do
# mock tasks for testing
%{
format: {"Formatting", "echo", ["Format check passed"]},
compile: {"Compile", "echo", ["Compile check passed"]},
compile_test: {"Compile (test)", "echo", ["Compile test check passed"]},
dialyzer: {"Dialyzer", "sh", ["-c", "sleep 2 && echo 'Dialyzer passed'"]},
credo: {"Credo", "sh", ["-c", "sleep 1 && echo 'Credo passed'"]},
credo_strict: {"Credo Strict", "sh", ["-c", "sleep 6 && exit 1"]}
}
else
checks_config = config["checks"] || @default_checks
checks_config
|> Enum.map(fn {key, value} ->
{name, cmd, args} = parse_check_config(key, value)
{String.to_atom(key), {name, cmd, args}}
end)
|> Map.new()
end
test_procs = test_procs(partitions)
test_path = test_dir || ""
# use --warnings-as-errors by default, but allow override via --test-args
base_flags = test_args || "--warnings-as-errors"
repeat_flags = if repeat, do: " --repeat-until-failure #{repeat}", else: ""
test_flags = base_flags <> repeat_flags
# generate test partition tasks
test_tasks =
for partition <- 1..max(partitions, 0)//1, into: %{} do
task_key = String.to_atom("test_#{partition}")
task_name = "Tests (#{partition}/#{partitions})"
task_def =
if mock_mode do
{task_name, "sh",
[
"-c",
"sleep 2 && echo '............\n\nFinished in 0.1s\n20 tests, 0 failures' && exit 0"
], partition, partitions}
else
{test_runner, coverage_flags} = test_runner_cmd(coverage, partition)
{task_name, "sh",
[
"-c",
"ELIXIR_ERL_OPTIONS='+S #{test_procs}:#{test_procs}' MIX_TEST_PARTITION=#{partition} mix #{test_runner} #{test_path} #{test_flags}#{coverage_flags} --partitions #{partitions}"
], partition, partitions}
end
{task_key, task_def}
end
Map.merge(base_tasks, test_tasks)
end
defp parse_check_config(key, %{"run" => run} = config) do
name = config["name"] || humanize_key(key)
{name, "sh", ["-c", run]}
end
defp humanize_key(key) do
key
|> String.replace("_", " ")
|> String.split()
|> Enum.map_join(" ", &String.capitalize/1)
end
defp select_tasks(all_tasks, opts, partitions, config) do
tasks =
case {opts[:fast], opts[:only]} do
# --fast overrides --only
{true, _} ->
fast_checks =
(config["fast"] || @default_fast)
|> Enum.map(fn name -> String.to_atom(to_string(name)) end)
fast_checks
|> Enum.map(&Map.get(all_tasks, &1))
|> Enum.reject(&is_nil/1)
# normal --only filter (when --fast is not provided or false)
{_, only_string} when is_binary(only_string) ->
# parse comma-separated check names
selected_checks =
only_string
|> String.split(",")
|> Enum.map(&String.trim/1)
|> Enum.map(&String.to_atom/1)
# expand checks (e.g., credo -> [credo, credo_strict], test -> [test_1, test_2, ...])
expanded_checks =
selected_checks
|> Enum.flat_map(&expand_check(&1, partitions))
|> Enum.uniq()
# filter and preserve order
expanded_checks
|> Enum.map(fn check ->
case Map.get(all_tasks, check) do
nil ->
IO.puts(:stderr, "Warning: Unknown check '#{check}', skipping...")
nil
task ->
task
end
end)
|> Enum.reject(&is_nil/1)
# no options, run all (when both --fast and --only are not provided)
{_, nil} ->
Map.values(all_tasks)
end
if Enum.empty?(tasks) do
IO.puts(:stderr, "Error: No valid checks specified")
System.halt(1)
end
tasks
end
defp build_test_cmd(test_dir, test_args, repeat, partitions, coverage) do
{runner, _} = test_runner_cmd(coverage, 1)
cover_flag = if coverage == :native, do: " --cover", else: ""
parts = ["mix #{runner}#{cover_flag}"]
parts = if test_dir, do: parts ++ [test_dir], else: parts
parts = parts ++ [test_args || "--warnings-as-errors"]
parts = if repeat, do: parts ++ ["--repeat-until-failure #{repeat}"], else: parts
parts = parts ++ ["--partitions #{partitions}"]
Enum.join(parts, " ")
end
defp parse_coverage("native"), do: :native
defp parse_coverage("coveralls"), do: :coveralls
defp parse_coverage(_), do: false
defp test_runner_cmd(:native, _partition),
do: {"test", " --cover"}
defp test_runner_cmd(:coveralls, _partition), do: {"coveralls", ""}
defp test_runner_cmd(_, _partition), do: {"test", ""}
defp merge_coverage(false), do: :ok
defp merge_coverage(:native) do
IO.puts([IO.ANSI.format([:cyan, "\nMerging coverage data..."])])
{output, status} =
System.cmd("mix", ["test.coverage"], stderr_to_stdout: true)
print_coverage_summary(output, "cover/", status)
end
defp merge_coverage(:coveralls) do
IO.puts([IO.ANSI.format([:cyan, "\nMerging coverage data..."])])
{output, status} =
System.cmd("mix", ["coveralls", "--import-cover", "cover/"], stderr_to_stdout: true)
print_coverage_summary(output, "cover/", status)
end
defp print_coverage_summary(output, dir, status) do
# match the "Total" line: | 16.92% | Total |
# or the "Coverage: 16.92%" line from threshold failure output
total_regex = ~r/(\d+\.?\d*)%\s*\|\s*Total|Coverage:\s+(\d+\.?\d*)%/
# match threshold from: "Threshold: 90.00%"
threshold =
case Regex.run(~r/Threshold:\s+(\d+\.?\d*)%/, output) do
[_, t] -> t
_ -> nil
end
case Regex.run(total_regex, output) do
[_, percentage, ""] ->
print_coverage_line(percentage, threshold, dir, status)
[_, "", percentage] ->
print_coverage_line(percentage, threshold, dir, status)
[_, percentage] ->
print_coverage_line(percentage, threshold, dir, status)
_ ->
IO.puts([IO.ANSI.format([:yellow, "Warning: Could not parse coverage from output"])])
IO.puts(output)
:ok
end
end
defp print_coverage_line(percentage, threshold, dir, status) do
pct = String.to_float(percentage)
color = if pct >= 80, do: :green, else: if(pct >= 50, do: :yellow, else: :red)
if status != 0 and threshold do
IO.puts([
IO.ANSI.format([
:red,
"✗ Coverage: #{percentage}% (threshold: #{threshold}%) | Report: #{dir}"
])
])
:failed
else
if status != 0 do
IO.puts([
IO.ANSI.format([
:red,
"✗ Coverage: #{percentage}% (threshold not met) | Report: #{dir}"
])
])
:failed
else
IO.puts([IO.ANSI.format([color, "✓ Coverage: #{percentage}% | Report: #{dir}"])])
:ok
end
end
end
defp run_checks(tasks, _repeat, test_cmd, max_concurrency) do
IO.puts("Running code quality checks in parallel...\n")
# print test command if test tasks are present
if has_test_tasks?(tasks) do
IO.puts([IO.ANSI.format([:cyan, "Test command: #{test_cmd}\n"])])
end
# show scheduler information
schedulers = :erlang.system_info(:schedulers_online)
IO.puts("Available schedulers: #{schedulers}\n")
# print initial status
Enum.each(tasks, fn task ->
name = elem(task, 0)
IO.puts(" • #{String.pad_trailing(name, 25)} [RUNNING]")
end)
# start time tracking
start_time = System.monotonic_time(:millisecond)
# start dot counter agent
{:ok, dot_counter_pid} = Agent.start_link(fn -> %{} end)
# run all tasks in parallel and update UI as each completes
results =
tasks
|> Enum.with_index()
|> Task.async_stream(
fn {task, index} ->
case task do
# test partition task (5-tuple)
{name, cmd, args, partition, total_partitions} ->
sleep = (rem(index, total_partitions) + 1) * 1000
Process.sleep(sleep)
{status, output} =
run_check_with_streaming(
cmd,
args,
index,
name,
length(tasks),
dot_counter_pid,
partition
)
# get final test counts for this partition
{total, failures} =
Agent.get(dot_counter_pid, fn state ->
total = Map.get(state, "partition_#{partition}_total", 0)
failures = Map.get(state, "partition_#{partition}_failures", 0)
{total, failures}
end)
{name, index, status, output, {total, failures}}
# regular task (3-tuple)
{name, cmd, args} ->
{status, output} = run_check(cmd, args)
{name, index, status, output, nil}
end
end,
timeout: :infinity,
ordered: false,
max_concurrency: max_concurrency
)
|> Enum.map(fn {:ok, result} ->
case result do
{name, index, status, output, test_count} ->
update_task_line(index, name, status, length(tasks), test_count)
{name, status, output}
end
end)
# stop dot counter agent
Agent.stop(dot_counter_pid)
# calculate total time
total_time = System.monotonic_time(:millisecond) - start_time
total_seconds = Float.round(total_time / 1000, 1)
{results, total_seconds}
end
defp print_summary(results, total_seconds, tasks, coverage) do
# move cursor to bottom
IO.write("\n")
# save credo outputs to files
save_credo_outputs(results)
# save format failure marker
save_format_failure_marker(results)
# merge test partition outputs into single file
merge_partition_outputs(results)
# extract and save failed tests only when test tasks ran
if has_test_tasks?(tasks) do
failed_tests = extract_failed_tests()
save_failed_tests(failed_tests)
end
# merge coverage data from all partitions
coverage_failed? =
if has_test_tasks?(tasks), do: merge_coverage(coverage) == :failed, else: false
failed_checks = Enum.filter(results, fn {_name, status, _output} -> status != 0 end)
IO.puts("\nCompleted in #{total_seconds}s")
cond do
Enum.empty?(failed_checks) and not coverage_failed? ->
IO.puts([IO.ANSI.format([:green, "\n✓ All checks passed!"])])
Enum.empty?(failed_checks) and coverage_failed? ->
System.halt(1)
true ->
IO.puts([IO.ANSI.format([:red, "\n#{length(failed_checks)} check(s) failed"])])
print_failure_details(failed_checks)
System.halt(1)
end
end
defp save_credo_outputs(results) do
File.mkdir_p!(".check")
results
|> Enum.filter(fn {name, _status, _output} ->
name in ["Credo", "Credo Strict"]
end)
|> Enum.each(fn {name, _status, output} ->
filename =
case name do
"Credo" -> ".check/credo.txt"
"Credo Strict" -> ".check/credo_strict.txt"
end
File.write!(filename, output)
end)
end
defp save_format_failure_marker(results) do
File.mkdir_p!(".check")
format_failed? =
Enum.any?(results, fn {name, status, _output} ->
name == "Formatting" and status != 0
end)
if format_failed? do
File.write!(".check/.format_failed", "")
else
# delete marker if format passed
File.rm(".check/.format_failed")
end
end
defp print_failure_details(failed_checks) do
IO.puts("\n" <> String.duplicate("=", 60))
IO.puts("FAILURE DETAILS")
IO.puts(String.duplicate("=", 60))
Enum.each(failed_checks, fn {name, _status, output} ->
IO.puts([IO.ANSI.format([:red, :bright, "\n#{name} failed:"])])
IO.puts(String.duplicate("-", 40))
if String.starts_with?(name, "Tests (") do
# test partition - show only summary line
summary = extract_test_summary(output)
IO.puts(summary)
IO.puts([IO.ANSI.format([:yellow, "\nFull test output saved to check_tests.txt"])])
else
# show all error output for non-test checks
lines =
output
|> String.split("\n")
|> Enum.reject(&(&1 == ""))
Enum.each(lines, fn line ->
# color code certain types of output
formatted_line =
cond do
String.contains?(line, "** ") ->
[IO.ANSI.format([:red, line])]
String.contains?(line, "warning:") ->
[IO.ANSI.format([:yellow, line])]
String.contains?(line, "error:") ->
[IO.ANSI.format([:red, line])]
String.contains?(line, "┃") or String.contains?(line, "│") ->
[IO.ANSI.format([:cyan, line])]
true ->
line
end
IO.puts(formatted_line)
end)
end
end)
end
defp merge_partition_outputs(results) do
# find all test partition results
partition_results =
results
|> Enum.filter(fn {name, _status, _output} ->
String.starts_with?(name, "Tests (")
end)
|> Enum.sort_by(fn {name, _status, _output} ->
# extract partition number from name like "Tests (3/5)"
case Regex.run(~r/Tests \((\d+)\/\d+\)/, name) do
[_, partition_str] -> String.to_integer(partition_str)
_ -> 0
end
end)
if Enum.any?(partition_results) do
timestamp = DateTime.utc_now() |> DateTime.to_string()
content =
[
"Test Output - Generated at #{timestamp}",
String.duplicate("=", 60),
""
] ++
Enum.flat_map(partition_results, fn {name, _status, output} ->
partition_num =
case Regex.run(~r/Tests \((\d+)\/(\d+)\)/, name) do
[_, p, t] -> "#{p}/#{t}"
_ -> "?"
end
[
"\nPARTITION #{partition_num}",
String.duplicate("-", 60),
output,
""
]
end)
File.mkdir_p!(".check")
File.write!(".check/check_tests.txt", Enum.join(content, "\n"))
# keep partition files for reference - users may want to review individual partition outputs
# partition files: .check/test_partition_1.txt, .check/test_partition_2.txt, etc.
end
end
defp extract_test_summary(output) when is_binary(output) do
# look for ExUnit summary line like "108 tests, 1 failure, 107 excluded"
case Regex.run(~r/(\d+ tests?, \d+ failures?(, \d+ excluded)?.*)/m, output) do
[_, summary | _] -> summary
nil -> "See .check/check_tests.txt for details"
end
end
defp extract_test_summary(_output) do
"See .check/check_tests.txt for details"
end
defp run_check(cmd, args) do
# suppress output unless there's an error
{output, status} = System.cmd(cmd, args, stderr_to_stdout: true)
{status, output}
end
defp run_check_with_streaming(cmd, args, index, name, total_tasks, dot_counter_pid, partition) do
# start a process to update UI periodically
parent = self()
updater_pid =
spawn_link(fn ->
update_loop(parent, index, name, total_tasks, dot_counter_pid)
end)
# create check directory and open file for real-time streaming
File.mkdir_p!(".check")
output_file = ".check/test_partition_#{partition}.txt"
file_handle = File.open!(output_file, [:write, :utf8])
# run command and collect output with streaming
port =
Port.open({:spawn_executable, System.find_executable(cmd)}, [
:binary,
:exit_status,
:stderr_to_stdout,
args: args
])
{output, status} = collect_port_output(port, "", dot_counter_pid, partition, file_handle)
# close file handle
File.close(file_handle)
# signal updater to stop
send(updater_pid, :stop)
final_status =
cond do
# fail if warnings detected in output (even if tests passed)
status == 0 and detect_warnings_in_output(output) -> 1
# ignore coverage threshold failure in partitions (merged coverage is checked later)
status != 0 and coverage_threshold_failure?(output) -> 0
true -> status
end
{final_status, output}
end
defp collect_port_output(port, acc, dot_counter_pid, partition, file_handle) do
receive do
{^port, {:data, data}} ->
# write data to file immediately for real-time streaming
IO.write(file_handle, data)
update_test_progress(data, dot_counter_pid, partition)
collect_port_output(port, acc <> data, dot_counter_pid, partition, file_handle)
{^port, {:exit_status, status}} ->
{acc, status}
end
end
defp update_test_progress(data, dot_counter_pid, partition) do
# update running test count
total_count = count_test_progress(data)
if total_count > 0 do
Agent.update(dot_counter_pid, fn state ->
key = "partition_#{partition}_total"
Map.update(state, key, total_count, &(&1 + total_count))
end)
end
# parse final ExUnit summary if present
parse_test_summary(data, dot_counter_pid, partition)
end
defp count_test_progress(data) do
dot_count = count_test_dots(data)
failure_count = count_failure_markers(data)
dot_count + failure_count
end
defp count_test_dots(data) do
data
|> String.split("\n")
|> Enum.map(&String.trim/1)
|> Enum.filter(&String.match?(&1, ~r/^[\.\*F]+$/))
|> Enum.map(fn line ->
line |> String.graphemes() |> Enum.count(&(&1 == "."))
end)
|> Enum.sum()
end
defp count_failure_markers(data) do
data
|> String.split("\n")
|> Enum.count(&Regex.match?(~r/^\s+\d+\)\s+test\s+/, &1))
end
defp parse_test_summary(data, dot_counter_pid, partition) do
# parse ExUnit summary line: "108 tests, 3 failures"
case Regex.run(~r/(\d+) tests?, (\d+) failures?/, data) do
[_, total_str, failures_str] ->
total = String.to_integer(total_str)
failures = String.to_integer(failures_str)
Agent.update(dot_counter_pid, fn state ->
state
|> Map.put("partition_#{partition}_total", total)
|> Map.put("partition_#{partition}_failures", failures)
end)
_ ->
:ok
end
end
defp update_loop(parent, index, name, total_tasks, dot_counter_pid) do
receive do
:stop -> :ok
after
1000 ->
# update UI every second
# extract partition number from task name like "Tests (3/5)"
test_counts =
case Regex.run(~r/Tests \((\d+)\/\d+\)/, name) do
[_, partition_str] ->
Agent.get(dot_counter_pid, fn state ->
total = Map.get(state, "partition_#{partition_str}_total", 0)
failures = Map.get(state, "partition_#{partition_str}_failures", 0)
{total, failures}
end)
_ ->
nil
end
update_task_line(index, name, :running, total_tasks, test_counts)
update_loop(parent, index, name, total_tasks, dot_counter_pid)
end
end
defp check_and_fix_format do
if File.exists?(".check/.format_failed") do
IO.puts([IO.ANSI.format([:red, "✗ Format check failed previously"])])
IO.puts([IO.ANSI.format([:yellow, "Running mix format to fix..."])])
{output, status} = System.cmd("mix", ["format"], stderr_to_stdout: true)
if status == 0 do
File.rm(".check/.format_failed")
IO.puts([IO.ANSI.format([:green, "✓ Format fixed successfully"])])
else
IO.puts(output)
IO.puts([IO.ANSI.format([:red, "✗ Format fix failed"])])
System.halt(1)
end
else
IO.puts([IO.ANSI.format([:green, "✓ Format check passed"])])
end
end
defp apply_fixes_from_stored_output do
IO.puts("Checking for failures to fix...\n")
# check and fix format issues first
check_and_fix_format()
IO.puts("\nReading stored credo outputs from check/...\n")
# read credo output files
files =
[".check/credo.txt", ".check/credo_strict.txt"]
|> Enum.filter(&File.exists?/1)
|> Enum.flat_map(fn file ->
content = File.read!(file)
extract_files_from_credo_output(content)
end)
|> Enum.uniq()
|> Enum.sort()
if Enum.empty?(files) do
IO.puts([
IO.ANSI.format([
:yellow,
"No credo errors found. Maybe rerun 'check --only credo'."
]),
IO.ANSI.reset()
])
else
IO.puts("Found #{length(files)} file(s) with issues:\n")
Enum.each(files, fn file -> IO.puts(" - #{file}") end)
IO.puts([
"\n",
IO.ANSI.format([:yellow, "Running mix recode on #{length(files)} file(s)..."]),
IO.ANSI.reset(),
"\n"
])
# run recode
{output, status} = System.cmd("mix", ["recode" | files], stderr_to_stdout: true)
IO.puts(output)
if status == 0 do
IO.puts([
IO.ANSI.format([:green, "\n✓ Auto-fixes applied successfully"]),
IO.ANSI.reset()
])
else
IO.puts([IO.ANSI.format([:red, "\n✗ Recode exited with errors"]), IO.ANSI.reset()])
System.halt(1)
end
end
end
defp extract_files_from_credo_output(output) do
# match lines like: " ┃ lib/foo/bar.ex:123:5" or "lib/foo/bar.ex:123"
~r/^\s*?\s+([^\s:]+\.exs?):\d+/m
|> Regex.scan(output)
|> Enum.map(fn [_, file] -> file end)
end
defp extract_failed_tests do
if File.exists?(".check/check_tests.txt") do
content = File.read!(".check/check_tests.txt")
# extract actual test failures
# match patterns like:
# 1) test description (Module.Name)
# test/path/to/file.exs:123
failures =
~r/\d+\)\s+test\s+.*?\n\s+(test\/[^\s:]+\.exs):(\d+)/m
|> Regex.scan(content)
|> Enum.map(fn [_, file, line] -> "#{file}:#{line}" end)
# extract warning locations
warnings = extract_warning_locations(content)
# combine and deduplicate
(failures ++ warnings) |> Enum.uniq()
else
[]
end
end
defp extract_warning_locations(content) do
# match warning location lines like:
# └─ test/path/to/file.exs:16:5: ModuleName."test name"/1
~r/└─ (test\/[^\s:]+\.exs):(\d+)/m
|> Regex.scan(content)
|> Enum.map(fn [_, file, line] -> "#{file}:#{line}" end)
end
defp save_failed_tests(failed_tests) do
if Enum.any?(failed_tests) do
File.mkdir_p!(".check")
content = Enum.join(failed_tests, "\n")
File.write!(".check/failed_tests.txt", content)
IO.puts([IO.ANSI.format([:yellow, "\nFailed tests saved to .check/failed_tests.txt"])])
end
end
defp run_failed_tests(repeat) do
failed_tests_file = ".check/failed_tests.txt"
unless File.exists?(failed_tests_file) do
IO.puts([
IO.ANSI.format([:red, "No failed tests found. Run 'check --only test' first."]),
IO.ANSI.reset()
])
System.halt(1)
end
failed_tests =
failed_tests_file
|> File.read!()
|> String.split("\n", trim: true)
if Enum.empty?(failed_tests) do
IO.puts([
IO.ANSI.format([:green, "No failed tests to run!"]),
IO.ANSI.reset()
])
else
IO.puts("Running #{length(failed_tests)} failed test(s)...\n")
# run mix test with all failed test files, streaming output
test_args = read_saved_test_args()
repeat_args = if repeat, do: ["--repeat-until-failure", to_string(repeat)], else: []
all_args = ["test"] ++ test_args ++ repeat_args ++ ["failed_tests"]
test_cmd = "mix " <> Enum.join(all_args, " ")
IO.puts([IO.ANSI.format([:cyan, "Test command: #{test_cmd}\n"])])
port =
Port.open({:spawn_executable, System.find_executable("mix")}, [
:binary,
:exit_status,
:stderr_to_stdout,
args: all_args ++ failed_tests
])
status = stream_port_output(port)
if status == 0 do
IO.puts([
IO.ANSI.format([:green, "\n✓ All previously failed tests now pass!"]),
IO.ANSI.reset()
])
else
IO.puts([
IO.ANSI.format([:red, "\n✗ Some tests still failing"]),
IO.ANSI.reset()
])
System.halt(1)
end
end
end
defp watch_partition_files do
# check if partition files exist
partition_files = Path.wildcard(".check/test_partition_*.txt")
if Enum.empty?(partition_files) do
IO.puts([
IO.ANSI.format([
:yellow,
"No test partition files found in .check/ directory.\n",
"Run tests first with: check --only test"
]),
IO.ANSI.reset()
])
System.halt(1)
end
IO.puts([
IO.ANSI.format([:green, "Watching #{length(partition_files)} test partition file(s)..."]),
IO.ANSI.reset(),
"\n"
])
# run tail -f -q on all partition files
port =
Port.open({:spawn_executable, System.find_executable("tail")}, [
:binary,
:exit_status,
:stderr_to_stdout,
args: ["-f", "-q", "-n", "+1" | partition_files]
])
stream_port_output(port)
end
defp stream_port_output(port) do
receive do
{^port, {:data, data}} ->
IO.binwrite(:stdio, data)
stream_port_output(port)
{^port, {:exit_status, status}} ->
status
end
end
# expand check names to include related checks
# e.g., credo expands to both credo and credo_strict
# test expands to all partitions
defp expand_check(:compile, _partitions), do: [:compile, :compile_test]
defp expand_check(:credo, _partitions), do: [:credo, :credo_strict]
defp expand_check(:test, partitions) do
for partition <- 1..partitions do
String.to_atom("test_#{partition}")
end
end
defp expand_check(check, _partitions), do: [check]
# check if any of the tasks are test-related
defp has_test_tasks?(tasks) do
Enum.any?(tasks, fn task ->
name = elem(task, 0)
String.starts_with?(name, "Tests (")
end)
end
defp save_test_args(test_args) do
File.mkdir_p!(".check")
test_flags = test_args || "--warnings-as-errors"
File.write!(".check/test_args.txt", test_flags)
end
defp read_saved_test_args do
case File.read(".check/test_args.txt") do
{:ok, content} -> content |> String.trim() |> String.split()
{:error, _} -> ["--warnings-as-errors"]
end
end
defp update_task_line(index, name, status, total_tasks, test_counts) do
# calculate how many lines up we need to go from current position
lines_up = total_tasks - index
{icon, color, text} = format_task_status(status, test_counts)
# save cursor position, move up, clear line, write status, restore cursor
IO.write([
"\e[s",
"\e[#{lines_up}A",
"\e[2K",
IO.ANSI.format([
color,
" #{icon} #{String.pad_trailing(name, 25)} #{text}\n"
]),
"\e[u"
])
end
defp format_task_status(status, test_counts) do
case {status, test_counts} do
# running state with test counts
{:running, {total, _failures}} when total > 0 ->
{"•", :yellow, "[RUNNING - #{total} tests]"}
# running state without test counts yet
{:running, _} ->
{"•", :yellow, "[RUNNING]"}
# success with test counts
{0, {total, _failures}} when total > 0 ->
{"✓", :green, "[OK - #{total} tests]"}
# success without test counts
{0, _} ->
{"✓", :green, "[OK]"}
# failure with test counts and failures
{_, {total, failures}} when failures > 0 ->
{"✗", :red, "[FAILED - #{failures}/#{total} tests]"}
# failure with test counts but no specific failure count
{_, {total, 0}} when total > 0 ->
{"✗", :red, "[FAILED - #{total} tests]"}
# failure without test counts (shouldn't happen for tests, but handle it)
_ ->
{"✗", :red, "[FAILED]"}
end
end
defp test_procs(partitions) do
schedulers = :erlang.system_info(:schedulers_online)
test_procs = floor(schedulers / partitions)
if test_procs > 0, do: test_procs, else: 1
end
defp detect_warnings_in_output(output) do
String.contains?(output, "warning:")
end
defp coverage_threshold_failure?(output) do
String.contains?(output, "Expected minimum coverage") and
Regex.match?(~r/\d+ tests?, 0 failures/, output)
end
end