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
scripts/check # Run all checks
scripts/check --fast # Run only fast checks (format, compile, credo)
scripts/check --only format,test # Run only format and test
scripts/check --only credo # Run credo and credo_strict
scripts/check --partitions 2 # Run tests with 2 partitions (default: 3)
scripts/check --dir test/bet_stack/markets/ # Run tests only from specific directory
scripts/check --fix # Apply fixes from stored credo output
scripts/check --failed # Re-run only failed tests from previous run
scripts/check --watch # Monitor test partition files in real-time
scripts/check mock # Run with mock commands for testing
scripts/check mock --only format,credo # Mock mode with credo (runs both basic & strict)
## 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:
scripts/check --only test # Run tests and save failures
cat check/failed_tests.txt # View failed tests
scripts/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:
scripts/check --only format,credo # Run checks and store failures
scripts/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)
"""
@spec main([String.t()]) :: :ok
def main(args) do
{opts, mock_mode, fix_mode} = parse_args(args)
cond do
# 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()
# if --fix flag, read stored outputs and apply fixes
fix_mode ->
apply_fixes_from_stored_output()
# normal check flow
true ->
partitions = opts[:partitions] || 3
test_dir = opts[:dir]
all_tasks = define_tasks(mock_mode, partitions, test_dir)
tasks = select_tasks(all_tasks, opts, partitions)
{results, total_seconds} = run_checks(tasks)
print_summary(results, total_seconds, tasks)
end
end
defp parse_args(args) do
{opts, remaining_args, _} =
OptionParser.parse(args,
strict: [
only: :string,
fix: :boolean,
fast: :boolean,
partitions: :integer,
failed: :boolean,
dir: :string,
watch: :boolean
]
)
mock_mode = "mock" in remaining_args
fix_mode = opts[:fix] || false
{opts, mock_mode, fix_mode}
end
defp define_tasks(mock_mode, partitions, test_dir) 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
# real tasks
%{
format: {"Formatting", "mix", ["format", "--check-formatted"]},
compile: {"Compile", "mix", ["compile", "--warnings-as-errors"]},
compile_test:
{"Compile (test)", "sh", ["-c", "MIX_ENV=test mix compile --warnings-as-errors"]},
dialyzer: {"Dialyzer", "mix", ["dialyzer"]},
credo: {"Credo", "mix", ["credo", "--all"]},
credo_strict:
{"Credo Strict", "mix", ["credo", "--strict", "--only", "readability", "--all"]}
}
end
test_procs = test_procs(partitions)
test_path = test_dir || ""
# generate test partition tasks
test_tasks =
for partition <- 1..partitions, 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
{task_name, "sh",
[
"-c",
"ELIXIR_ERL_OPTIONS='+S #{test_procs}:#{test_procs}' MIX_TEST_PARTITION=#{partition} mix test #{test_path} --partitions #{partitions}"
], partition, partitions}
end
{task_key, task_def}
end
Map.merge(base_tasks, test_tasks)
end
defp select_tasks(all_tasks, opts, partitions) do
tasks =
case {opts[:fast], opts[:only]} do
# --fast overrides --only
{true, _} ->
# run only: format, compile, compile_test, credo, credo_strict
[:format, :compile, :compile_test, :credo, :credo_strict]
|> 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 run_checks(tasks) do
IO.puts("Running code quality checks in parallel...\n")
# 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: 10
)
|> 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) 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
failed_checks = Enum.filter(results, fn {_name, status, _output} -> status != 0 end)
IO.puts("\nCompleted in #{total_seconds}s")
if Enum.empty?(failed_checks) do
IO.puts([IO.ANSI.format([:green, "\n✓ All checks passed!"])])
else
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)
{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 'scripts/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")
# match patterns like:
# 1) test description (Module.Name)
# test/path/to/file.exs:123
~r/\d+\)\s+test\s+.*?\n\s+(test\/[^\s:]+\.exs):(\d+)/m
|> Regex.scan(content)
|> Enum.map(fn [_, file, line] -> "#{file}:#{line}" end)
|> Enum.uniq()
else
[]
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 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 'scripts/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
port =
Port.open({:spawn_executable, System.find_executable("mix")}, [
:binary,
:exit_status,
:stderr_to_stdout,
args: ["test" | 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: scripts/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 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
end