Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ def cli do
end

defp deps do
[{:six, "~> 0.3", only: :test}]
[{:six, "~> 0.4", only: :test}]
end
```

Expand Down Expand Up @@ -58,7 +58,7 @@ mix six.html --open
This produces two things:

1. A terminal summary table (sorted worst-first)
2. `.six/coverage.md` - a structured report an AI agent can read and act on
2. `.six/coverage.md` - a structured, grep-able report an AI agent can read and act on. Plain `key: value` header lines, a fixed-schema summary line, and detail only for what needs attention - uncovered code. Fully covered files are counted, not listed.

## Guides

Expand Down
37 changes: 32 additions & 5 deletions guides/ai-integration.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,24 +2,51 @@

## The agent report

The `.six/coverage.md` report is the reason Six exists. Instead of just listing line numbers, it tells an agent what to do:
The `.six/coverage.md` report is the reason Six exists. It is written for machines, not humans: plain `key: value` header lines, a fixed-schema summary line, and detail only where coverage is missing. Fully covered files are counted, not listed, so the report stays small no matter how large the project gets.

### lib/my_app/accounts/auth.ex - 62.5% (15/24)
# Six Coverage Report

total: 87.2% (654/750 relevant lines)
threshold: 90.0% (fail)
generated: 2026-07-03T01:31:24Z

## Uncovered files (worst first)

### lib/my_app/accounts/auth.ex — 62.5% (15/24)

**Missed lines:**

- **Lines 45-52** - `authenticate` - the `{:error, ...}` branch
- **Lines 45-52** `authenticate` the `{:error, ...}` branch

{:error, :expired_token} ->
Logger.warning("Token expired for user #{user_id}")
{:error, :session_expired}

- **Lines 78-84** - `refresh_session` - entire function untested
- **Lines 78-84** `refresh_session` entire function untested

def refresh_session(%Session{} = session) do
...
end

## Ignored

3 ignored ranges in 2 files. Grep these files for `six:ignore` and `@six :ignore` markers to audit whether each exclusion is still justified.

- lib/my_app/cover.ex (2)
- lib/my_app/release.ex (1)

## Summary

files: 18, relevant: 750, covered: 654, missed: 96, fully_covered: 12, below_threshold: 3

Instead of just listing line numbers, the uncovered sections tell an agent what to do: which function each missed range belongs to, which branch went unexercised, and the source itself.

The header and summary fields are stable across runs — every field appears on every run, even when zero — so scripts and agents can grep them directly:

grep '^total:' .six/coverage.md
grep -q 'threshold:.*(fail)' .six/coverage.md && echo "below threshold"
grep -o 'missed: [0-9]*' .six/coverage.md

Use it with Claude Code:

@.six/coverage.md write tests for the uncovered branches
Expand Down Expand Up @@ -76,6 +103,6 @@ This runs `mix test --cover`, reads the report, and writes tests for uncovered b

/project:six focus on the Auth module

The report also includes an **Ignored** section listing every function and line range excluded from coverage, so you can audit whether ignores are still justified.
The report also includes an **Ignored** section listing each file that contains coverage exclusions (with a count of ignored ranges), so an agent can grep those files for `six:ignore` and `@six :ignore` markers and audit whether the exclusions are still justified.

For longer-running projects, enable `track_ignores: true` (or pass `--track-ignores`) to write a committable `.sixignore` manifest at the project root. Every new exclusion shows up as a line in your PR diff, so no ignore can land without explicit review. See the README for details.
3 changes: 1 addition & 2 deletions guides/reading-output.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,9 @@ The summary table has five columns:
| **RELEVANT** | Lines Six considers coverable (after filtering out boilerplate) |
| **MISSED** | Relevant lines with zero executions |

Rows are sorted worst-first so the files that need attention are at the top.
Rows are sorted worst-first so the files that need attention are at the top. Only files with missed lines get a row; fully covered files are collapsed into a single `N files fully covered (not shown)` line above the total.

## Colors

- Green means coverage is at or above the threshold (default 90%).
- Red means coverage is below the threshold.
- Yellow means the file has 0 relevant lines, so every executable line was filtered out (all `defmodule`, `use`, `alias`, `end`, etc.). There is nothing to cover, so Six cannot score it. This is normal for files that are purely structural, like a module that only defines a struct or delegates.
83 changes: 31 additions & 52 deletions lib/six/formatters/agent.ex
Original file line number Diff line number Diff line change
Expand Up @@ -25,22 +25,21 @@ defmodule Six.Formatters.Agent do
@doc false
def render(summary, opts) do
threshold = Keyword.get(opts, :threshold, 90)
threshold_status = if summary.percentage >= threshold, do: "", else: ""
threshold_status = if summary.percentage >= threshold, do: "pass", else: "fail"

{uncovered, covered} =
Enum.split_with(summary.files, fn f -> f.missed > 0 end)

uncovered = Enum.sort_by(uncovered, & &1.percentage)

[
"# Six Coverage Report\n",
"**Total: #{format_pct(summary.percentage)}** (#{summary.total_covered}/#{summary.total_relevant} relevant lines covered)\n",
"**Generated:** #{DateTime.utc_now() |> DateTime.to_iso8601()}\n",
"**Threshold:** #{format_pct(threshold)} #{threshold_status}\n",
"# Six Coverage Report\n\n",
"total: #{format_pct(summary.percentage)} (#{summary.total_covered}/#{summary.total_relevant} relevant lines)\n",
"threshold: #{format_pct(threshold)} (#{threshold_status})\n",
"generated: #{DateTime.utc_now() |> DateTime.to_iso8601()}\n",
render_uncovered(uncovered),
render_covered(covered),
render_ignored(summary.files),
render_summary(summary, threshold)
render_summary(summary, length(covered), threshold)
]
|> IO.iodata_to_binary()
end
Expand Down Expand Up @@ -84,63 +83,43 @@ defmodule Six.Formatters.Agent do
["\n## Uncovered files (worst first)\n" | sections]
end

defp render_covered([]), do: "\n## Fully covered files\n\n_None_\n"

defp render_covered(files) do
lines = Enum.map(files, fn f -> "- #{f.path} — #{format_pct(f.percentage)}\n" end)
["\n## Fully covered files\n\n" | lines]
end

defp render_ignored(files) do
all_ignored =
Enum.flat_map(files, fn file ->
comment_ranges = Six.Ignore.ignored_ranges(file.source)
func_ranges = Six.Ignore.Functions.ignored_functions(file.source)

comment_entries =
Enum.map(comment_ranges, fn {start_line, end_line, type} ->
type_label = if type == :block, do: "six:ignore:start/stop", else: "six:ignore:next"

%{
path: file.path,
start_line: start_line,
end_line: end_line,
label: type_label,
function: nil
}
end)

func_entries =
Enum.map(func_ranges, fn %{start_line: s, end_line: e, function: func} ->
%{path: file.path, start_line: s, end_line: e, label: "@six :ignore", function: func}
end)

comment_entries ++ func_entries
ignored_counts =
files
|> Enum.map(fn file ->
count =
length(Six.Ignore.ignored_ranges(file.source)) +
length(Six.Ignore.Functions.ignored_functions(file.source))

{file.path, count}
end)
|> Enum.filter(fn {_path, count} -> count > 0 end)

if all_ignored == [] do
if ignored_counts == [] do
""
else
lines =
Enum.map(all_ignored, fn entry ->
range = "#{entry.path}:#{entry.start_line}-#{entry.end_line}"
func_part = if entry.function, do: " `#{entry.function}`", else: ""
"- #{range}#{func_part} — #{entry.label}\n"
end)

["\n## Ignored\n\n" | lines]
total = ignored_counts |> Enum.map(&elem(&1, 1)) |> Enum.sum()

lines = Enum.map(ignored_counts, fn {path, count} -> "- #{path} (#{count})\n" end)

[
"\n## Ignored\n\n",
"#{total} ignored ranges in #{length(ignored_counts)} files. ",
"Grep these files for `six:ignore` and `@six :ignore` markers ",
"to audit whether each exclusion is still justified.\n\n"
| lines
]
end
end

defp render_summary(summary, threshold) do
defp render_summary(summary, covered_count, threshold) do
below = Enum.count(summary.files, fn f -> f.percentage < threshold end)
file_count = length(summary.files)

[
"\n## Summary\n\n",
"#{file_count} files, #{summary.total_relevant} relevant lines, ",
"#{summary.total_covered} covered, #{summary.total_missed} missed.\n",
if(below > 0, do: "#{below} files below threshold (#{format_pct(threshold)}).\n", else: "")
"files: #{length(summary.files)}, relevant: #{summary.total_relevant}, ",
"covered: #{summary.total_covered}, missed: #{summary.total_missed}, ",
"fully_covered: #{covered_count}, below_threshold: #{below}\n"
]
end

Expand Down
29 changes: 21 additions & 8 deletions lib/six/formatters/terminal.ex
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,19 @@ defmodule Six.Formatters.Terminal do
filter = Keyword.get(opts, :filter, nil)
threshold = Keyword.get(opts, :threshold, 90)

path_width = max_path_width(summary.files)
{uncovered, covered} = Enum.split_with(summary.files, fn f -> f.missed > 0 end)

path_width = max_path_width(uncovered)

IO.puts("")
print_separator()
print_header(path_width)
print_files(summary.files, threshold, path_width)

if uncovered != [] do
print_header(path_width)
print_files(uncovered, threshold, path_width)
end

print_covered_count(length(covered))
print_total(summary.percentage, threshold)
print_separator()

Expand Down Expand Up @@ -43,7 +50,7 @@ defmodule Six.Formatters.Terminal do

Enum.each(sorted, fn file ->
cov_str = format_percentage(file.percentage)
color = color_for(file.percentage, file.relevant, threshold)
color = color_for(file.percentage, threshold)

line =
pad_right(cov_str, 7) <>
Expand All @@ -63,8 +70,15 @@ defmodule Six.Formatters.Terminal do
end)
end

defp print_covered_count(0), do: :ok

defp print_covered_count(count) do
label = if count == 1, do: "file", else: "files"
IO.puts("#{count} #{label} fully covered (not shown)")
end

defp print_total(percentage, threshold) do
color = color_for(percentage, 1, threshold)
color = color_for(percentage, threshold)
line = "[TOTAL] #{format_percentage(percentage)}"

# six:ignore:start
Expand Down Expand Up @@ -132,9 +146,8 @@ defmodule Six.Formatters.Terminal do
:erlang.float_to_binary(pct, decimals: 1) <> "%"
end

defp color_for(_pct, 0, _threshold), do: IO.ANSI.yellow()
defp color_for(pct, _, threshold) when pct >= threshold, do: IO.ANSI.green()
defp color_for(_, _, _), do: IO.ANSI.red()
defp color_for(pct, threshold) when pct >= threshold, do: IO.ANSI.green()
defp color_for(_, _), do: IO.ANSI.red()

defp pad_right(str, width), do: String.pad_trailing(str, width)
defp pad_left(str, width), do: String.pad_leading(str, width)
Expand Down
2 changes: 1 addition & 1 deletion mix.exs
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
defmodule Six.MixProject do
use Mix.Project

@version "0.3.1"
@version "0.4.0"
@source_url "https://github.com/typicalpixel/six"

def project do
Expand Down
27 changes: 17 additions & 10 deletions test/formatters/agent_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ defmodule Six.Formatters.AgentTest do
content = Agent.render(sample_summary(), threshold: 90)

assert content =~ "# Six Coverage Report"
assert content =~ "Total: 50.0%"
assert content =~ "total: 50.0%"
assert content =~ "lib/foo.ex"
assert content =~ "Uncovered files"
end
Expand All @@ -66,14 +66,14 @@ defmodule Six.Formatters.AgentTest do

test "render shows threshold status" do
content = Agent.render(sample_summary(), threshold: 90)
assert content =~ ""
assert content =~ "threshold: 90.0% (fail)"

passing_summary = %{sample_summary() | percentage: 95.0}
content2 = Agent.render(passing_summary, threshold: 90)
assert content2 =~ ""
assert content2 =~ "threshold: 90.0% (pass)"
end

test "render lists fully covered files" do
test "render counts fully covered files instead of listing them" do
summary = %{
files: [
%{
Expand All @@ -95,9 +95,16 @@ defmodule Six.Formatters.AgentTest do
}

content = Agent.render(summary, threshold: 90)
assert content =~ "Fully covered files"
assert content =~ "lib/good.ex"
assert content =~ "100.0%"
assert content =~ "fully_covered: 1"
refute content =~ "Fully covered files"
refute content =~ "lib/good.ex"
end

test "render summary line always includes all fields" do
content = Agent.render(sample_summary(), threshold: 90)

assert content =~
"files: 1, relevant: 6, covered: 3, missed: 3, fully_covered: 0, below_threshold: 1"
end

test "render with no uncovered files" do
Expand Down Expand Up @@ -355,9 +362,9 @@ defmodule Six.Formatters.AgentTest do

content = Agent.render(summary, threshold: 90)
assert content =~ "## Ignored"
assert content =~ "@six :ignore"
assert content =~ "def excluded"
assert content =~ "six:ignore:next"
assert content =~ "2 ignored ranges in 1 files"
assert content =~ "- lib/foo.ex (2)"
refute content =~ "def excluded"
end

test "render omits ignored section when no ignores present" do
Expand Down
Loading
Loading