Current section
Files
Jump to
Current section
Files
lib/check/coverage.ex
defmodule Check.Coverage do
@moduledoc "Coverage merging, caching, and threshold checking."
@coverage_cache_path ".check/coverage_cache"
@cover_modified_dir ".check/cover_modified"
def merge(%{mod: false}), do: :ok
def merge(%{mod: :native, html: html} = coverage) do
IO.puts([IO.ANSI.format([:cyan, "\nMerging coverage data..."])])
current_hash = coverdata_hash()
case read_cache(current_hash) do
{:ok, cached_output} ->
IO.puts([IO.ANSI.format([:cyan, "(cached)"])])
maybe_print_full(cached_output, coverage)
check(cached_output, "cover/", coverage)
:miss ->
port = Check.Port.open("mix", ["test.coverage"])
output =
if html do
{out, _status} = collect_port_until_exit(port, "")
out
else
{out, _status} = collect_coverage_output(port, "")
out
end
write_cache(current_hash, output)
maybe_print_full(output, coverage)
check(output, "cover/", coverage)
end
end
def merge(%{mod: :coveralls} = coverage) do
IO.puts([IO.ANSI.format([:cyan, "\nMerging coverage data..."])])
{output, _status} =
System.cmd("mix", ["coveralls", "--import-cover", "cover/"], stderr_to_stdout: true)
maybe_print_full(output, coverage)
check(output, "cover/", coverage)
end
# Print the entire coverage table when `--full-coverage-output` is set.
defp maybe_print_full(output, %{full: true}), do: IO.puts(output)
defp maybe_print_full(_output, _coverage), do: :ok
# Stream output from mix test.coverage, kill once Total line is found
defp collect_coverage_output(port, acc) do
receive do
{^port, {:data, data}} ->
acc = acc <> data
if String.contains?(acc, "| Total") do
Port.close(port)
receive do
{^port, {:exit_status, status}} -> {acc, status}
after
100 -> {acc, 0}
end
else
collect_coverage_output(port, acc)
end
{^port, {:exit_status, status}} ->
{acc, status}
end
end
defp collect_port_until_exit(port, acc) do
receive do
{^port, {:data, data}} -> collect_port_until_exit(port, acc <> data)
{^port, {:exit_status, status}} -> {acc, status}
end
end
def check(output, dir, coverage) when is_map(coverage) do
case parse_total_percentage(output) do
nil ->
IO.puts([IO.ANSI.format([:yellow, "Warning: Could not parse coverage from output"])])
IO.puts(output)
:ok
percentage ->
result = report_coverage(percentage, dir, coverage.limit)
compare_baseline(percentage, coverage.baseline_cmd)
result
end
end
# backward compat for direct limit value
def check(output, dir, limit) do
check(output, dir, %{limit: limit, baseline_cmd: nil})
end
defp parse_total_percentage(output) do
case Regex.run(~r/(\d+\.?\d*)%\s*\|\s*Total|Coverage:\s+(\d+\.?\d*)%/, output) do
[_, percentage, ""] -> String.to_float(percentage)
[_, "", percentage] -> String.to_float(percentage)
[_, percentage] -> String.to_float(percentage)
_ -> nil
end
end
defp report_coverage(percentage, dir, limit) when is_number(limit) and percentage < limit do
IO.puts([
IO.ANSI.format([:red, "✗ Coverage: #{percentage}% (limit: #{limit}%) | #{html_url(dir)}"])
])
:failed
end
defp report_coverage(percentage, dir, _limit) do
color = if percentage >= 80, do: :green, else: :yellow
IO.puts([IO.ANSI.format([color, "✓ Coverage: #{percentage}% | #{html_url(dir)}"])])
:ok
end
defp html_url(dir) do
"file://#{Path.join(File.cwd!(), dir)}"
end
defp compare_baseline(_percentage, nil), do: :ok
defp compare_baseline(percentage, baseline_cmd) do
case System.cmd("sh", ["-c", baseline_cmd], stderr_to_stdout: true) do
{output, 0} ->
case Float.parse(String.trim(output)) do
{baseline, _} ->
print_delta(percentage, baseline)
:error ->
IO.puts([
IO.ANSI.format([
:yellow,
"Warning: Could not parse baseline coverage from: #{String.trim(output)}"
])
])
end
{error, _} ->
IO.puts([
IO.ANSI.format([:yellow, "Warning: Baseline command failed: #{String.trim(error)}"])
])
end
end
defp print_delta(percentage, baseline) do
delta = Float.round(percentage - baseline, 2)
cond do
delta > 0 ->
IO.puts([IO.ANSI.format([:green, " Coverage: +#{delta}% vs baseline (#{baseline}%)"])])
delta < 0 ->
IO.puts([IO.ANSI.format([:red, " Coverage: #{delta}% vs baseline (#{baseline}%)"])])
true ->
IO.puts([IO.ANSI.format([:cyan, " Coverage: same as baseline (#{baseline}%)"])])
end
end
def maybe_merge_and_show_modified do
case Check.Config.load() do
{:ok, config} ->
coverage = Check.Config.parse_coverage(config["coverage"])
if coverage.mod != false do
# merge without threshold/baseline - just collect the data
merge_silent(coverage)
show_modified_files_coverage()
end
_ ->
:ok
end
end
defp merge_silent(%{mod: :native}) do
current_hash = coverdata_hash()
case read_cache(current_hash) do
{:ok, _cached} ->
:ok
:miss ->
port = Check.Port.open("mix", ["test.coverage"])
# collect all output silently - don't print anything
{output, _status} = collect_port_until_exit(port, "")
write_cache(current_hash, output)
end
end
defp merge_silent(_), do: :ok
def show_modified_files_coverage do
cache_file = @coverage_cache_path <> ".txt"
config =
case Check.Config.load() do
{:ok, c} -> c
_ -> %{}
end
with {:ok, output} <- File.read(cache_file),
base_branch when is_binary(base_branch) <- Check.Config.base_branch(config) do
print_modified_coverage(output, base_branch)
end
end
defp print_modified_coverage(output, base_branch) do
new_modules = get_ex_files_by_filter(base_branch, "A") |> modules_from_files()
# Diffing across two revisions can surface an added-then-modified file in
# both groups; keep it only under "new".
modified_modules =
(get_ex_files_by_filter(base_branch, "M") |> modules_from_files()) -- new_modules
new_lines = filter_coverage_lines(output, new_modules)
modified_lines = filter_coverage_lines(output, modified_modules)
print_coverage_group("Coverage of new files:", new_lines, :green)
print_coverage_group("Coverage of modified files:", modified_lines, :yellow)
copy_modified_htmls(new_modules ++ modified_modules)
end
defp modules_from_files(files), do: Enum.flat_map(files, &extract_modules_from_file/1)
defp print_coverage_group(_title, [], _color), do: :ok
defp print_coverage_group(title, lines, color) do
IO.puts([IO.ANSI.format([color, "\n#{title}"])])
Enum.each(lines, fn line -> IO.puts(line) end)
case avg_percentage(lines) do
nil -> :ok
avg -> IO.puts([IO.ANSI.format([color, " Average: #{avg}%"])])
end
end
defp avg_percentage(lines) do
percentages =
lines
|> Enum.map(fn line ->
case Regex.run(~r/(\d+\.?\d*)%/, line) do
[_, pct] -> parse_number(pct)
_ -> nil
end
end)
|> Enum.reject(&is_nil/1)
if Enum.empty?(percentages) do
nil
else
(Enum.sum(percentages) / length(percentages)) |> Float.round(2)
end
end
defp parse_number(str) do
case Float.parse(str) do
{num, _} ->
num
:error ->
case Integer.parse(str) do
{num, _} -> num * 1.0
:error -> nil
end
end
end
defp copy_modified_htmls([]), do: :ok
defp copy_modified_htmls(modules) do
cwd = File.cwd!()
src_dir = Path.join(cwd, "cover")
dest_dir = Path.join(cwd, @cover_modified_dir)
File.mkdir_p!(dest_dir)
Enum.each(modules, fn module ->
src = Path.join(src_dir, "Elixir.#{module}.html")
if File.exists?(src) do
File.cp!(src, Path.join(dest_dir, "Elixir.#{module}.html"))
end
end)
# Copy shared assets so HTML files render correctly
case File.ls(src_dir) do
{:ok, entries} ->
entries
|> Enum.filter(&(String.ends_with?(&1, ".css") or String.ends_with?(&1, ".js")))
|> Enum.each(fn asset ->
File.cp!(Path.join(src_dir, asset), Path.join(dest_dir, asset))
end)
_ ->
:ok
end
IO.puts([IO.ANSI.format([:cyan, "\n Limited coverage result: file://#{dest_dir}/"])])
end
defp get_ex_files_by_filter(base_branch, filter) do
base_branch
|> diff_revisions()
|> Enum.flat_map(&run_name_diff(&1, filter))
|> Enum.uniq()
end
# Revisions to diff "lib" files against, depending on whether we're sitting on
# the base branch itself. `base...HEAD` (three-dot) collapses to nothing when
# the current branch *is* the base, so committing straight to main would never
# report any modified files.
#
# - feature branch: branch commits since base (base...HEAD) + uncommitted (HEAD)
# - base branch: last commit and uncommitted (HEAD~1), or just uncommitted on a root commit
defp diff_revisions(base_branch) do
if Check.Git.current_branch() == base_branch do
if Check.Git.parent_commit?(), do: ["HEAD~1"], else: ["HEAD"]
else
["#{base_branch}...HEAD", "HEAD"]
end
end
defp run_name_diff(revision, filter) do
# `:(glob)` magic is required for `**` to span directories; a plain
# `lib/**/*.ex` pathspec silently matches nothing for top-level files.
case System.cmd(
"git",
[
"diff",
"--name-only",
"--diff-filter=#{filter}",
revision,
"--",
":(glob)lib/**/*.ex"
],
stderr_to_stdout: true
) do
{output, 0} -> String.split(output, "\n", trim: true)
_ -> []
end
end
# Read file and extract all defmodule names
defp extract_modules_from_file(path) do
if File.exists?(path) do
path
|> File.read!()
|> then(&Regex.scan(~r/defmodule\s+([\w.]+)/, &1))
|> Enum.map(fn [_, module] -> module end)
else
[]
end
end
# Match the module cell exactly - a substring match would make a top-level
# module like "Check" pull in every "Check.*" row.
defp filter_coverage_lines(output, modules) do
module_set = MapSet.new(modules)
output
|> String.split("\n")
|> Enum.filter(fn line ->
case Regex.run(~r/\|\s*([\w.]+)\s*\|/, line) do
[_, module] -> MapSet.member?(module_set, module)
_ -> false
end
end)
end
defp coverdata_hash do
Path.wildcard("cover/*.coverdata")
|> Enum.sort()
|> Enum.reduce(:crypto.hash_init(:md5), fn path, acc ->
:crypto.hash_update(acc, File.read!(path))
end)
|> :crypto.hash_final()
|> Base.encode16(case: :lower)
end
defp read_cache(current_hash) do
hash_file = @coverage_cache_path <> ".hash"
output_file = @coverage_cache_path <> ".txt"
with {:ok, cached_hash} <- File.read(hash_file),
true <- String.trim(cached_hash) == current_hash,
{:ok, output} <- File.read(output_file) do
{:ok, output}
else
_ -> :miss
end
end
defp write_cache(hash, output) do
File.mkdir_p!(".check")
File.write!(@coverage_cache_path <> ".hash", hash)
File.write!(@coverage_cache_path <> ".txt", output)
end
end