From b095ecce830c7223cf0b819390ffac6d5f10d1b8 Mon Sep 17 00:00:00 2001 From: Erik Berlin Date: Sat, 4 Apr 2026 10:07:07 -0700 Subject: [PATCH 1/8] Add richer metrics to JSON formatter Enhance the JSON coverage output with per-file coverage percentages, a total section with aggregate statistics, full group stats, and method coverage support. Per-file output now includes covered_percent (always), and when enabled: branches_covered_percent and methods array with methods_covered_percent. The total and groups sections report full statistics (covered, missed, total, percent, strength) for each enabled coverage type (line, branch, method). Breaking change: group stats change from { covered_percent: 80.0 } to the full stats shape with the key renamed to percent. Based on the ideas in https://github.com/codeclimate-community/simplecov_json_formatter/pull/12. Co-Authored-By: Tejas --- CHANGELOG.md | 20 +++++ features/step_definitions/json_steps.rb | 9 ++- .../json_formatter/result_hash_formatter.rb | 29 +++++-- .../json_formatter/source_file_formatter.rb | 37 +++++++-- spec/fixtures/json/sample.json | 12 ++- spec/fixtures/json/sample_groups.json | 18 ++++- spec/fixtures/json/sample_with_branch.json | 20 ++++- spec/fixtures/json/sample_with_method.json | 81 +++++++++++++++++++ spec/json_formatter_spec.rb | 64 ++++++++++++++- 9 files changed, 269 insertions(+), 21 deletions(-) create mode 100644 spec/fixtures/json/sample_with_method.json diff --git a/CHANGELOG.md b/CHANGELOG.md index d99693a66..42037d092 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,8 +1,28 @@ Unreleased ========== +## Breaking Changes +* JSON formatter: group stats changed from `{ "covered_percent": 80.0 }` to full stats shape `{ "covered": 8, "missed": 2, "total": 10, "percent": 80.0, "strength": 0.0 }`. The key `covered_percent` is renamed to `percent`. +* JSON formatter: `simplecov_json_formatter` gem is now built in. `require "simplecov_json_formatter"` continues to work via a shim. +* `StringFilter` now matches at path-segment boundaries. `"lib"` matches `/lib/` but no longer matches `/library/`. Use a `Regexp` filter for substring matching. +* Removed `docile` gem dependency. The `SimpleCov.configure` DSL block is now evaluated via `instance_exec` with instance variable proxying. + +## Enhancements +* JSON formatter: added `total` section with aggregate coverage statistics (covered, missed, total, percent, strength) for line, branch, and method coverage +* JSON formatter: per-file output now includes `lines_covered_percent`, and when enabled: `branches_covered_percent`, `methods` array, and `methods_covered_percent` +* JSON formatter: group stats now include full statistics for all enabled coverage types, not just line coverage percent +* JSON formatter: added `silent:` keyword to `JSONFormatter.new` to suppress console output +* Merged `simplecov-html` formatter into the main gem. A backward-compatibility shim ensures `require "simplecov-html"` still works. +* Merged `simplecov_json_formatter` into the main gem. A backward-compatibility shim ensures `require "simplecov_json_formatter"` still works. +* Added `rake assets:compile` task for building the HTML formatter's frontend assets via esbuild +* Added TypeScript type checking CI workflow +* Separated rubocop into its own `lint.yml` CI workflow +* `CommandGuesser` now appends the framework name to parallel test data (e.g. `"RSpec (1/2)"` instead of `"(1/2)"`) + ## Bugfixes * Don't report misleading 100% branch/method coverage for files added via `track_files` that were never loaded. See #902 +* Fix HTML formatter tab bar layout: dark mode toggle no longer wraps onto two lines, and tabs connect seamlessly with the content panel +* Fix branch coverage cucumber feature to match the HTML formatter's updated output format 0.22.1 (2024-09-02) ========== diff --git a/features/step_definitions/json_steps.rb b/features/step_definitions/json_steps.rb index a66f14617..fd94548e5 100644 --- a/features/step_definitions/json_steps.rb +++ b/features/step_definitions/json_steps.rb @@ -6,7 +6,12 @@ coverage_hash = json_report.fetch "coverage" directory = Dir.pwd - expect(coverage_hash.fetch("#{directory}/lib/faked_project.rb")).to eq "lines" => [nil, nil, 1, 1, 1, nil, nil, nil, 5, 3, nil, nil, 1] - expect(coverage_hash.fetch("#{directory}/lib/faked_project/some_class.rb")).to eq "lines" => [nil, nil, 1, 1, 1, nil, 1, 2, nil, nil, 1, 1, nil, nil, 1, 1, 1, nil, 0, nil, nil, 0, nil, nil, 1, nil, 1, 0, nil, nil] + faked_project = coverage_hash.fetch("#{directory}/lib/faked_project.rb") + expect(faked_project["lines"]).to eq [nil, nil, 1, 1, 1, nil, nil, nil, 5, 3, nil, nil, 1] + expect(faked_project["lines_covered_percent"]).to be_a(Float) + + some_class = coverage_hash.fetch("#{directory}/lib/faked_project/some_class.rb") + expect(some_class["lines"]).to eq [nil, nil, 1, 1, 1, nil, 1, 2, nil, nil, 1, 1, nil, nil, 1, 1, 1, nil, 0, nil, nil, 0, nil, nil, 1, nil, 1, 0, nil, nil] + expect(some_class["lines_covered_percent"]).to be_a(Float) end end diff --git a/lib/simplecov/formatter/json_formatter/result_hash_formatter.rb b/lib/simplecov/formatter/json_formatter/result_hash_formatter.rb index 3e92c2d9f..9029ee39d 100644 --- a/lib/simplecov/formatter/json_formatter/result_hash_formatter.rb +++ b/lib/simplecov/formatter/json_formatter/result_hash_formatter.rb @@ -11,6 +11,7 @@ def initialize(result) end def format + format_total format_files format_groups @@ -19,6 +20,10 @@ def format private + def format_total + formatted_result[:total] = format_coverage_statistics(@result.coverage_statistics) + end + def format_files @result.files.each do |source_file| formatted_result[:coverage][source_file.filename] = @@ -28,11 +33,7 @@ def format_files def format_groups @result.groups.each do |name, file_list| - formatted_result[:groups][name] = { - lines: { - covered_percent: file_list.covered_percent - } - } + formatted_result[:groups][name] = format_coverage_statistics(file_list.coverage_statistics) end end @@ -41,6 +42,7 @@ def formatted_result meta: { simplecov_version: SimpleCov::VERSION }, + total: {}, coverage: {}, groups: {} } @@ -50,6 +52,23 @@ def format_source_file(source_file) source_file_formatter = SourceFileFormatter.new(source_file) source_file_formatter.format end + + def format_coverage_statistics(statistics) + result = {lines: format_single_statistic(statistics[:line])} + result[:branches] = format_single_statistic(statistics[:branch]) if SimpleCov.branch_coverage? && statistics[:branch] + result[:methods] = format_single_statistic(statistics[:method]) if SimpleCov.method_coverage? && statistics[:method] + result + end + + def format_single_statistic(stat) + { + covered: stat.covered, + missed: stat.missed, + total: stat.total, + percent: stat.percent, + strength: stat.strength + } + end end end end diff --git a/lib/simplecov/formatter/json_formatter/source_file_formatter.rb b/lib/simplecov/formatter/json_formatter/source_file_formatter.rb index 67303e4b0..246eca3e7 100644 --- a/lib/simplecov/formatter/json_formatter/source_file_formatter.rb +++ b/lib/simplecov/formatter/json_formatter/source_file_formatter.rb @@ -10,24 +10,32 @@ def initialize(source_file) end def format - if SimpleCov.branch_coverage? - line_coverage.merge(branch_coverage) - else - line_coverage - end + result = line_coverage + result.merge!(branch_coverage) if SimpleCov.branch_coverage? + result.merge!(method_coverage) if SimpleCov.method_coverage? + result end private def line_coverage @line_coverage ||= { - lines: lines + lines: lines, + lines_covered_percent: @source_file.covered_percent } end def branch_coverage { - branches: branches + branches: branches, + branches_covered_percent: @source_file.branches_coverage_percent + } + end + + def method_coverage + { + methods: format_methods, + methods_covered_percent: @source_file.methods_coverage_percent } end @@ -43,6 +51,12 @@ def branches end end + def format_methods + @source_file.methods.collect do |method| + parse_method(method) + end + end + def parse_line(line) return line.coverage unless line.skipped? @@ -57,6 +71,15 @@ def parse_branch(branch) coverage: parse_line(branch) } end + + def parse_method(method) + { + name: method.to_s, + start_line: method.start_line, + end_line: method.end_line, + coverage: parse_line(method) + } + end end end end diff --git a/spec/fixtures/json/sample.json b/spec/fixtures/json/sample.json index 55a4e7a03..7f6c04537 100644 --- a/spec/fixtures/json/sample.json +++ b/spec/fixtures/json/sample.json @@ -2,6 +2,15 @@ "meta": { "simplecov_version": "0.22.0" }, + "total": { + "lines": { + "covered": 9, + "missed": 1, + "total": 10, + "percent": 90.0, + "strength": 1.0 + } + }, "coverage": { "/STUB_WORKING_DIRECTORY/spec/fixtures/json/sample.rb": { "lines": [ @@ -30,7 +39,8 @@ "ignored", "ignored", null - ] + ], + "lines_covered_percent": 90.0 } }, "groups": {} diff --git a/spec/fixtures/json/sample_groups.json b/spec/fixtures/json/sample_groups.json index 1c47f59cd..0171fb6dc 100644 --- a/spec/fixtures/json/sample_groups.json +++ b/spec/fixtures/json/sample_groups.json @@ -2,6 +2,15 @@ "meta": { "simplecov_version": "0.22.0" }, + "total": { + "lines": { + "covered": 9, + "missed": 1, + "total": 10, + "percent": 90.0, + "strength": 1.0 + } + }, "coverage": { "/STUB_WORKING_DIRECTORY/spec/fixtures/json/sample.rb": { "lines": [ @@ -30,13 +39,18 @@ "ignored", "ignored", null - ] + ], + "lines_covered_percent": 90.0 } }, "groups": { "My Group": { "lines": { - "covered_percent": 80.0 + "covered": 8, + "missed": 2, + "total": 10, + "percent": 80.0, + "strength": 0.0 } } } diff --git a/spec/fixtures/json/sample_with_branch.json b/spec/fixtures/json/sample_with_branch.json index ac451e807..39ac96018 100644 --- a/spec/fixtures/json/sample_with_branch.json +++ b/spec/fixtures/json/sample_with_branch.json @@ -2,6 +2,22 @@ "meta": { "simplecov_version": "0.22.0" }, + "total": { + "lines": { + "covered": 9, + "missed": 1, + "total": 10, + "percent": 90.0, + "strength": 1.0 + }, + "branches": { + "covered": 1, + "missed": 1, + "total": 2, + "percent": 50.0, + "strength": 0.0 + } + }, "coverage": { "/STUB_WORKING_DIRECTORY/spec/fixtures/json/sample.rb": { "lines": [ @@ -31,6 +47,7 @@ "ignored", null ], + "lines_covered_percent": 90.0, "branches": [ { "type": "then", @@ -44,7 +61,8 @@ "end_line": 16, "coverage": 1 } - ] + ], + "branches_covered_percent": 50.0 } }, "groups": {} diff --git a/spec/fixtures/json/sample_with_method.json b/spec/fixtures/json/sample_with_method.json new file mode 100644 index 000000000..f13c7d201 --- /dev/null +++ b/spec/fixtures/json/sample_with_method.json @@ -0,0 +1,81 @@ +{ + "meta": { + "simplecov_version": "0.22.0" + }, + "total": { + "lines": { + "covered": 9, + "missed": 1, + "total": 10, + "percent": 90.0, + "strength": 1.0 + }, + "methods": { + "covered": 3, + "missed": 0, + "total": 3, + "percent": 100.0, + "strength": 0.0 + } + }, + "coverage": { + "/STUB_WORKING_DIRECTORY/spec/fixtures/json/sample.rb": { + "lines": [ + null, + 1, + 1, + 1, + 1, + null, + null, + 1, + 1, + null, + null, + 1, + 1, + 0, + null, + 1, + null, + null, + null, + "ignored", + "ignored", + "ignored", + "ignored", + "ignored", + null + ], + "lines_covered_percent": 90.0, + "methods": [ + { + "name": "Foo#initialize", + "start_line": 3, + "end_line": 6, + "coverage": 1 + }, + { + "name": "Foo#bar", + "start_line": 8, + "end_line": 10, + "coverage": 1 + }, + { + "name": "Foo#foo", + "start_line": 12, + "end_line": 18, + "coverage": 1 + }, + { + "name": "Foo#skipped", + "start_line": 21, + "end_line": 23, + "coverage": "ignored" + } + ], + "methods_covered_percent": 100.0 + } + }, + "groups": {} +} diff --git a/spec/json_formatter_spec.rb b/spec/json_formatter_spec.rb index 533ca2b1b..3d75bb0fd 100644 --- a/spec/json_formatter_spec.rb +++ b/spec/json_formatter_spec.rb @@ -16,10 +16,24 @@ describe "format" do context "with line coverage" do - it "works" do + it "includes line coverage and covered_percent per file" do subject.format(result) expect(json_output).to eq(json_result("sample")) end + + it "preserves raw percentage and strength precision" do + unrounded_result = SimpleCov::Result.new({source_fixture("json/sample.rb") => {"lines" => [1, 0, 1]}}) + + subject.format(unrounded_result) + + expect(json_output.fetch("total").fetch("lines")).to include( + "percent" => 66.66666666666667, + "strength" => 0.6666666666666666 + ) + expect(json_output.fetch("coverage").fetch(source_fixture("json/sample.rb"))).to include( + "lines_covered_percent" => 66.66666666666667 + ) + end end context "with branch coverage" do @@ -51,13 +65,51 @@ enable_branch_coverage end - it "works" do + it "includes branch data and branches_covered_percent per file" do subject.format(result) expect(json_output).to eq(json_result("sample_with_branch")) end end + context "with method coverage" do + let(:original_lines) do + [nil, 1, 1, 1, 1, nil, nil, 1, 1, + nil, nil, 1, 1, 0, nil, 1, nil, + nil, nil, nil, 1, 0, nil, nil, nil] + end + + let(:original_methods) do + { + ["Foo", :initialize, 3, 2, 6, 5] => 1, + ["Foo", :bar, 8, 2, 10, 5] => 1, + ["Foo", :foo, 12, 2, 18, 5] => 1, + ["Foo", :skipped, 21, 2, 23, 5] => 0 + } + end + + let(:result) do + SimpleCov::Result.new({ + source_fixture("json/sample.rb") => { + "lines" => original_lines, + "methods" => original_methods + } + }) + end + + before do + enable_method_coverage + end + + # total.methods.total is 3, not 4, because Foo#skipped is inside a :nocov: block + it "includes methods array and methods_covered_percent per file" do + subject.format(result) + expect(json_output).to eq(json_result("sample_with_method")) + end + end + context "with groups" do + let(:line_stats) { SimpleCov::CoverageStatistics.new(covered: 8, missed: 2) } + let(:result) do res = SimpleCov::Result.new({ source_fixture("json/sample.rb") => {"lines" => [ @@ -68,7 +120,9 @@ # right now SimpleCov works mostly on global state, hence setting the groups that way # would be global state --> Mocking is better here - allow(res).to receive_messages(groups: {"My Group" => double("File List", covered_percent: 80.0)}) + allow(res).to receive_messages( + groups: {"My Group" => double("File List", coverage_statistics: {line: line_stats})} + ) res end @@ -83,6 +137,10 @@ def enable_branch_coverage allow(SimpleCov).to receive(:branch_coverage?).and_return(true) end + def enable_method_coverage + allow(SimpleCov).to receive(:method_coverage?).and_return(true) + end + def json_output JSON.parse(File.read("tmp/coverage/coverage.json")) end From db8f6d9836f2e06d8ab29abc784be894103e418e Mon Sep 17 00:00:00 2001 From: Erik Berlin Date: Mon, 6 Apr 2026 19:59:10 -0600 Subject: [PATCH 2/8] Add errors to JSON output for files below minimum coverage Report all four types of coverage threshold violations in the JSON formatter's errors object: minimum_coverage, minimum_coverage_by_file, minimum_coverage_by_group, and maximum_coverage_drop. Each violation includes expected and actual values. This makes the JSON self-contained for downstream consumers (e.g. CI reporters) that don't have access to the Ruby process. Co-Authored-By: Tejas --- .../json_formatter/result_hash_formatter.rb | 81 ++++++++- spec/fixtures/json/sample.json | 3 +- spec/fixtures/json/sample_groups.json | 3 +- spec/fixtures/json/sample_with_branch.json | 3 +- spec/fixtures/json/sample_with_method.json | 3 +- spec/json_formatter_spec.rb | 157 ++++++++++++++++++ 6 files changed, 245 insertions(+), 5 deletions(-) diff --git a/lib/simplecov/formatter/json_formatter/result_hash_formatter.rb b/lib/simplecov/formatter/json_formatter/result_hash_formatter.rb index 9029ee39d..63b5a0eda 100644 --- a/lib/simplecov/formatter/json_formatter/result_hash_formatter.rb +++ b/lib/simplecov/formatter/json_formatter/result_hash_formatter.rb @@ -14,6 +14,7 @@ def format format_total format_files format_groups + format_errors formatted_result end @@ -37,6 +38,83 @@ def format_groups end end + def format_errors + format_minimum_coverage_errors + format_minimum_coverage_by_file_errors + format_minimum_coverage_by_group_errors + format_maximum_coverage_drop_errors + end + + CRITERION_KEYS = {line: :lines, branch: :branches, method: :methods}.freeze + private_constant :CRITERION_KEYS + + def format_minimum_coverage_errors + SimpleCov.minimum_coverage.each do |criterion, expected_percent| + actual = @result.coverage_statistics.fetch(criterion).percent + next unless actual < expected_percent + + key = CRITERION_KEYS.fetch(criterion) + minimum_coverage = formatted_result[:errors][:minimum_coverage] ||= {} + minimum_coverage[key] = {expected: expected_percent, actual: actual} + end + end + + def format_minimum_coverage_by_file_errors + SimpleCov.minimum_coverage_by_file.each do |criterion, expected_percent| + @result.files.each do |file| + actual = SimpleCov.round_coverage(file.coverage_statistics.fetch(criterion).percent) + next unless actual < expected_percent + + key = CRITERION_KEYS.fetch(criterion) + by_file = formatted_result[:errors][:minimum_coverage_by_file] ||= {} + criterion_errors = by_file[key] ||= {} + criterion_errors[file.filename] = {expected: expected_percent, actual: actual} + end + end + end + + def format_minimum_coverage_by_group_errors + SimpleCov.minimum_coverage_by_group.each do |group_name, minimum_group_coverage| + group = @result.groups[group_name] + next unless group + + minimum_group_coverage.each do |criterion, expected_percent| + actual = SimpleCov.round_coverage(group.coverage_statistics.fetch(criterion).percent) + next unless actual < expected_percent + + key = CRITERION_KEYS.fetch(criterion) + by_group = formatted_result[:errors][:minimum_coverage_by_group] ||= {} + group_errors = by_group[group_name] ||= {} + group_errors[key] = {expected: expected_percent, actual: actual} + end + end + end + + def format_maximum_coverage_drop_errors + return if SimpleCov.maximum_coverage_drop.empty? + + last_run = SimpleCov::LastRun.read + return unless last_run + + SimpleCov.maximum_coverage_drop.each do |criterion, max_drop| + drop = coverage_drop_for(criterion, last_run) + next unless drop && drop > max_drop + + key = CRITERION_KEYS.fetch(criterion) + coverage_drop = formatted_result[:errors][:maximum_coverage_drop] ||= {} + coverage_drop[key] = {maximum: max_drop, actual: drop} + end + end + + def coverage_drop_for(criterion, last_run) + last_coverage_percent = last_run.dig(:result, criterion) + last_coverage_percent ||= last_run.dig(:result, :covered_percent) if criterion == :line + return nil unless last_coverage_percent + + current = SimpleCov.round_coverage(@result.coverage_statistics.fetch(criterion).percent) + (last_coverage_percent - current).floor(10) + end + def formatted_result @formatted_result ||= { meta: { @@ -44,7 +122,8 @@ def formatted_result }, total: {}, coverage: {}, - groups: {} + groups: {}, + errors: {} } end diff --git a/spec/fixtures/json/sample.json b/spec/fixtures/json/sample.json index 7f6c04537..06c1c9477 100644 --- a/spec/fixtures/json/sample.json +++ b/spec/fixtures/json/sample.json @@ -43,5 +43,6 @@ "lines_covered_percent": 90.0 } }, - "groups": {} + "groups": {}, + "errors": {} } diff --git a/spec/fixtures/json/sample_groups.json b/spec/fixtures/json/sample_groups.json index 0171fb6dc..8e0723a3b 100644 --- a/spec/fixtures/json/sample_groups.json +++ b/spec/fixtures/json/sample_groups.json @@ -53,5 +53,6 @@ "strength": 0.0 } } - } + }, + "errors": {} } diff --git a/spec/fixtures/json/sample_with_branch.json b/spec/fixtures/json/sample_with_branch.json index 39ac96018..e7bf0aeb0 100644 --- a/spec/fixtures/json/sample_with_branch.json +++ b/spec/fixtures/json/sample_with_branch.json @@ -65,5 +65,6 @@ "branches_covered_percent": 50.0 } }, - "groups": {} + "groups": {}, + "errors": {} } diff --git a/spec/fixtures/json/sample_with_method.json b/spec/fixtures/json/sample_with_method.json index f13c7d201..99c8f6795 100644 --- a/spec/fixtures/json/sample_with_method.json +++ b/spec/fixtures/json/sample_with_method.json @@ -77,5 +77,6 @@ "methods_covered_percent": 100.0 } }, - "groups": {} + "groups": {}, + "errors": {} } diff --git a/spec/json_formatter_spec.rb b/spec/json_formatter_spec.rb index 3d75bb0fd..cc31ab7cd 100644 --- a/spec/json_formatter_spec.rb +++ b/spec/json_formatter_spec.rb @@ -107,6 +107,163 @@ end end + context "with minimum_coverage below threshold" do + before do + allow(SimpleCov).to receive(:minimum_coverage).and_return(line: 95) + end + + it "reports the violation in errors" do + subject.format(result) + errors = json_output.fetch("errors") + expect(errors).to eq( + "minimum_coverage" => {"lines" => {"expected" => 95, "actual" => 90.0}} + ) + end + end + + context "with minimum_coverage above threshold" do + before do + allow(SimpleCov).to receive(:minimum_coverage).and_return(line: 80) + end + + it "returns empty errors" do + subject.format(result) + expect(json_output.fetch("errors")).to eq({}) + end + end + + context "with minimum_coverage_by_file for lines" do + before do + allow(SimpleCov).to receive(:minimum_coverage_by_file).and_return(line: 95) + end + + it "reports files below the threshold in errors" do + subject.format(result) + errors = json_output.fetch("errors") + expect(errors).to eq( + "minimum_coverage_by_file" => { + "lines" => {source_fixture("json/sample.rb") => {"expected" => 95, "actual" => 90.0}} + } + ) + end + end + + context "with minimum_coverage_by_file for branches" do + let(:result) do + SimpleCov::Result.new({ + source_fixture("json/sample.rb") => { + "lines" => [nil, 1, 1, 1, 1, nil, nil, 1, 1, nil, nil, + 1, 1, 0, nil, 1, nil, nil, nil, nil, 1, 0, nil, nil, nil], + "branches" => { + [:if, 0, 13, 4, 17, 7] => { + [:then, 1, 14, 6, 14, 10] => 0, + [:else, 2, 16, 6, 16, 10] => 1 + } + } + } + }) + end + + before do + enable_branch_coverage + allow(SimpleCov).to receive(:minimum_coverage_by_file).and_return(branch: 75) + end + + it "reports files below the threshold in errors" do + subject.format(result) + errors = json_output.fetch("errors") + expect(errors).to eq( + "minimum_coverage_by_file" => { + "branches" => {source_fixture("json/sample.rb") => {"expected" => 75, "actual" => 50.0}} + } + ) + end + end + + context "with minimum_coverage_by_file when all files pass" do + before do + allow(SimpleCov).to receive(:minimum_coverage_by_file).and_return(line: 80) + end + + it "returns empty errors" do + subject.format(result) + expect(json_output.fetch("errors")).to eq({}) + end + end + + context "with minimum_coverage_by_group below threshold" do + let(:line_stats) { SimpleCov::CoverageStatistics.new(covered: 7, missed: 3) } + + let(:result) do + res = SimpleCov::Result.new({ + source_fixture("json/sample.rb") => {"lines" => [ + nil, 1, 1, 1, 1, nil, nil, 1, 1, nil, nil, + 1, 1, 0, nil, 1, nil, nil, nil, nil, 1, 0, nil, nil, nil + ]} + }) + + allow(res).to receive_messages( + groups: {"Models" => double("File List", coverage_statistics: {line: line_stats})} + ) + res + end + + before do + allow(SimpleCov).to receive(:minimum_coverage_by_group).and_return("Models" => {line: 80}) + end + + it "reports the group violation in errors" do + subject.format(result) + errors = json_output.fetch("errors") + expect(errors).to eq( + "minimum_coverage_by_group" => { + "Models" => {"lines" => {"expected" => 80, "actual" => 70.0}} + } + ) + end + end + + context "with maximum_coverage_drop exceeded" do + before do + allow(SimpleCov).to receive(:maximum_coverage_drop).and_return(line: 2) + allow(SimpleCov::LastRun).to receive(:read).and_return({result: {line: 95.0}}) + end + + it "reports the drop in errors" do + subject.format(result) + errors = json_output.fetch("errors") + expect(errors).to eq( + "maximum_coverage_drop" => { + "lines" => {"maximum" => 2, "actual" => 5.0} + } + ) + end + end + + context "with maximum_coverage_drop not exceeded" do + before do + allow(SimpleCov).to receive(:maximum_coverage_drop).and_return(line: 2) + allow(SimpleCov::LastRun).to receive(:read).and_return({result: {line: 91.0}}) + end + + it "returns empty errors" do + subject.format(result) + expect(json_output.fetch("errors")).to eq({}) + end + end + + context "with maximum_coverage_drop and no last run" do + before do + allow(SimpleCov).to receive(:maximum_coverage_drop).and_return(line: 2) + allow(SimpleCov::LastRun).to receive(:read).and_return(nil) + end + + it "returns empty errors" do + subject.format(result) + expect(json_output.fetch("errors")).to eq({}) + end + end + context "with groups" do let(:line_stats) { SimpleCov::CoverageStatistics.new(covered: 8, missed: 2) } From 31604ad0f233c388195e113c118109e1f14c16c1 Mon Sep 17 00:00:00 2001 From: Erik Berlin Date: Thu, 16 Apr 2026 16:45:19 -0700 Subject: [PATCH 3/8] Add `omitted` field to JSON line stats Expose the count of blank/comment lines (SimpleCov's `never_lines`) in the line coverage statistics so downstream consumers can distinguish lines that cannot be covered from lines that weren't covered. Only line stats carry `omitted`; branches and methods have no blank/comment analogue. --- CHANGELOG.md | 2 +- lib/simplecov/coverage_statistics.rb | 13 ++++++++----- .../json_formatter/result_hash_formatter.rb | 13 ++++++++++++- lib/simplecov/source_file.rb | 3 ++- spec/coverage_statistics_spec.rb | 19 ++++++++++++++----- spec/fixtures/json/sample.json | 1 + spec/fixtures/json/sample_groups.json | 2 ++ spec/fixtures/json/sample_with_branch.json | 1 + spec/fixtures/json/sample_with_method.json | 1 + 9 files changed, 42 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 42037d092..574b9bb84 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,7 @@ Unreleased * Removed `docile` gem dependency. The `SimpleCov.configure` DSL block is now evaluated via `instance_exec` with instance variable proxying. ## Enhancements -* JSON formatter: added `total` section with aggregate coverage statistics (covered, missed, total, percent, strength) for line, branch, and method coverage +* JSON formatter: added `total` section with aggregate coverage statistics (covered, missed, total, percent, strength) for line, branch, and method coverage. Line stats additionally include `omitted` (count of blank/comment lines, i.e. lines that cannot be covered) * JSON formatter: per-file output now includes `lines_covered_percent`, and when enabled: `branches_covered_percent`, `methods` array, and `methods_covered_percent` * JSON formatter: group stats now include full statistics for all enabled coverage types, not just line coverage percent * JSON formatter: added `silent:` keyword to `JSONFormatter.new` to suppress console output diff --git a/lib/simplecov/coverage_statistics.rb b/lib/simplecov/coverage_statistics.rb index a9666f264..cff423cc3 100644 --- a/lib/simplecov/coverage_statistics.rb +++ b/lib/simplecov/coverage_statistics.rb @@ -8,32 +8,35 @@ module SimpleCov # * total - how many things to cover there are (total relevant loc/branches) # * covered - how many of the coverables are hit # * missed - how many of the coverables are missed + # * omitted - how many lines cannot be covered (blank lines/comments); only meaningful for line coverage # * percent - percentage as covered/missed # * strength - average hits per/coverable (will not exist for one shot lines format) class CoverageStatistics - attr_reader :total, :covered, :missed, :strength, :percent + attr_reader :total, :covered, :missed, :omitted, :strength, :percent def self.from(coverage_statistics) - sum_covered, sum_missed, sum_total_strength = - coverage_statistics.reduce([0, 0, 0.0]) do |(covered, missed, total_strength), file_coverage_statistics| + sum_covered, sum_missed, sum_omitted, sum_total_strength = + coverage_statistics.reduce([0, 0, 0, 0.0]) do |(covered, missed, omitted, total_strength), file_coverage_statistics| [ covered + file_coverage_statistics.covered, missed + file_coverage_statistics.missed, + omitted + file_coverage_statistics.omitted, # gotta remultiply with loc because files have different strength and loc # giving them a different "weight" in total total_strength + (file_coverage_statistics.strength * file_coverage_statistics.total) ] end - new(covered: sum_covered, missed: sum_missed, total_strength: sum_total_strength) + new(covered: sum_covered, missed: sum_missed, omitted: sum_omitted, total_strength: sum_total_strength) end # Requires only covered, missed and strength to be initialized. # # Other values are computed by this class. - def initialize(covered:, missed:, total_strength: 0.0, percent: nil) + def initialize(covered:, missed:, omitted: 0, total_strength: 0.0, percent: nil) @covered = covered @missed = missed + @omitted = omitted @total = covered + missed @percent = percent || compute_percent(covered, missed, total) @strength = compute_strength(total_strength, total) diff --git a/lib/simplecov/formatter/json_formatter/result_hash_formatter.rb b/lib/simplecov/formatter/json_formatter/result_hash_formatter.rb index 63b5a0eda..44a1d0218 100644 --- a/lib/simplecov/formatter/json_formatter/result_hash_formatter.rb +++ b/lib/simplecov/formatter/json_formatter/result_hash_formatter.rb @@ -133,12 +133,23 @@ def format_source_file(source_file) end def format_coverage_statistics(statistics) - result = {lines: format_single_statistic(statistics[:line])} + result = {lines: format_line_statistic(statistics[:line])} result[:branches] = format_single_statistic(statistics[:branch]) if SimpleCov.branch_coverage? && statistics[:branch] result[:methods] = format_single_statistic(statistics[:method]) if SimpleCov.method_coverage? && statistics[:method] result end + def format_line_statistic(stat) + { + covered: stat.covered, + missed: stat.missed, + omitted: stat.omitted, + total: stat.total, + percent: stat.percent, + strength: stat.strength + } + end + def format_single_statistic(stat) { covered: stat.covered, diff --git a/lib/simplecov/source_file.rb b/lib/simplecov/source_file.rb index cd874c881..8a2818e4a 100644 --- a/lib/simplecov/source_file.rb +++ b/lib/simplecov/source_file.rb @@ -402,7 +402,8 @@ def line_coverage_statistics line: CoverageStatistics.new( total_strength: lines_strength, covered: covered_lines.size, - missed: missed_lines.size + missed: missed_lines.size, + omitted: never_lines.size ) } end diff --git a/spec/coverage_statistics_spec.rb b/spec/coverage_statistics_spec.rb index 234b4a84b..3b6b73f31 100644 --- a/spec/coverage_statistics_spec.rb +++ b/spec/coverage_statistics_spec.rb @@ -5,17 +5,24 @@ RSpec.describe SimpleCov::CoverageStatistics do describe ".new" do it "retains statistics and computes new ones" do - statistics = described_class.new(covered: 4, missed: 6, total_strength: 14) + statistics = described_class.new(covered: 4, missed: 6, omitted: 2, total_strength: 14) expect(statistics.covered).to eq 4 expect(statistics.missed).to eq 6 + expect(statistics.omitted).to eq 2 expect(statistics.total).to eq 10 expect(statistics.percent).to eq 40.0 expect(statistics.strength).to eq 1.4 end - it "can omit the total strength defaulting to 0.0" do + it "defaults omitted to 0" do + statistics = described_class.new(covered: 4, missed: 6) + + expect(statistics.omitted).to eq 0 + end + + it "can omitted the total strength defaulting to 0.0" do statistics = described_class.new(covered: 4, missed: 6) expect(statistics.strength).to eq 0.0 @@ -44,14 +51,15 @@ it "produces sensible total results" do statistics = described_class.from( [ - described_class.new(covered: 3, missed: 4, total_strength: 54), - described_class.new(covered: 0, missed: 13, total_strength: 0), - described_class.new(covered: 37, missed: 0, total_strength: 682) + described_class.new(covered: 3, missed: 4, omitted: 2, total_strength: 54), + described_class.new(covered: 0, missed: 13, omitted: 5, total_strength: 0), + described_class.new(covered: 37, missed: 0, omitted: 8, total_strength: 682) ] ) expect(statistics.covered).to eq 40 expect(statistics.missed).to eq 17 + expect(statistics.omitted).to eq 15 expect(statistics.total).to eq 57 expect(statistics.percent).to be_within(0.01).of(70.18) expect(statistics.strength).to be_within(0.01).of(12.91) @@ -65,6 +73,7 @@ def empty_statistics def expect_all_empty(statistics) expect(statistics.covered).to eq 0 expect(statistics.missed).to eq 0 + expect(statistics.omitted).to eq 0 expect(statistics.total).to eq 0 # might be counter-intuitive but think of it as "we covered everything we could" diff --git a/spec/fixtures/json/sample.json b/spec/fixtures/json/sample.json index 06c1c9477..35e6929d9 100644 --- a/spec/fixtures/json/sample.json +++ b/spec/fixtures/json/sample.json @@ -6,6 +6,7 @@ "lines": { "covered": 9, "missed": 1, + "omitted": 10, "total": 10, "percent": 90.0, "strength": 1.0 diff --git a/spec/fixtures/json/sample_groups.json b/spec/fixtures/json/sample_groups.json index 8e0723a3b..bf632dcc8 100644 --- a/spec/fixtures/json/sample_groups.json +++ b/spec/fixtures/json/sample_groups.json @@ -6,6 +6,7 @@ "lines": { "covered": 9, "missed": 1, + "omitted": 10, "total": 10, "percent": 90.0, "strength": 1.0 @@ -48,6 +49,7 @@ "lines": { "covered": 8, "missed": 2, + "omitted": 0, "total": 10, "percent": 80.0, "strength": 0.0 diff --git a/spec/fixtures/json/sample_with_branch.json b/spec/fixtures/json/sample_with_branch.json index e7bf0aeb0..903ebb0d5 100644 --- a/spec/fixtures/json/sample_with_branch.json +++ b/spec/fixtures/json/sample_with_branch.json @@ -6,6 +6,7 @@ "lines": { "covered": 9, "missed": 1, + "omitted": 10, "total": 10, "percent": 90.0, "strength": 1.0 diff --git a/spec/fixtures/json/sample_with_method.json b/spec/fixtures/json/sample_with_method.json index 99c8f6795..743d40556 100644 --- a/spec/fixtures/json/sample_with_method.json +++ b/spec/fixtures/json/sample_with_method.json @@ -6,6 +6,7 @@ "lines": { "covered": 9, "missed": 1, + "omitted": 10, "total": 10, "percent": 90.0, "strength": 1.0 From 8cb80de8c97a9afb4fb7f2796ca1673beb4dd190 Mon Sep 17 00:00:00 2001 From: Erik Berlin Date: Thu, 16 Apr 2026 16:54:42 -0700 Subject: [PATCH 4/8] Drop dev-only entries from CHANGELOG Build tooling, CI workflow changes, and a cucumber-feature fix aren't relevant to consumers reading the changelog. --- CHANGELOG.md | 4 ---- 1 file changed, 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 574b9bb84..09fa1a3c9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,15 +14,11 @@ Unreleased * JSON formatter: added `silent:` keyword to `JSONFormatter.new` to suppress console output * Merged `simplecov-html` formatter into the main gem. A backward-compatibility shim ensures `require "simplecov-html"` still works. * Merged `simplecov_json_formatter` into the main gem. A backward-compatibility shim ensures `require "simplecov_json_formatter"` still works. -* Added `rake assets:compile` task for building the HTML formatter's frontend assets via esbuild -* Added TypeScript type checking CI workflow -* Separated rubocop into its own `lint.yml` CI workflow * `CommandGuesser` now appends the framework name to parallel test data (e.g. `"RSpec (1/2)"` instead of `"(1/2)"`) ## Bugfixes * Don't report misleading 100% branch/method coverage for files added via `track_files` that were never loaded. See #902 * Fix HTML formatter tab bar layout: dark mode toggle no longer wraps onto two lines, and tabs connect seamlessly with the content panel -* Fix branch coverage cucumber feature to match the HTML formatter's updated output format 0.22.1 (2024-09-02) ========== From 76d246e49d47b47b35d1eaf0e906dbfcb66b2395 Mon Sep 17 00:00:00 2001 From: Erik Berlin Date: Thu, 16 Apr 2026 16:55:02 -0700 Subject: [PATCH 5/8] Round coverage percent in minimum_coverage errors Match the rounding done in minimum_coverage_by_file, minimum_coverage_by_group, and maximum_coverage_drop so the `actual` value reported in errors is consistent across all thresholds. --- lib/simplecov/formatter/json_formatter/result_hash_formatter.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/simplecov/formatter/json_formatter/result_hash_formatter.rb b/lib/simplecov/formatter/json_formatter/result_hash_formatter.rb index 44a1d0218..04c69e96a 100644 --- a/lib/simplecov/formatter/json_formatter/result_hash_formatter.rb +++ b/lib/simplecov/formatter/json_formatter/result_hash_formatter.rb @@ -50,7 +50,7 @@ def format_errors def format_minimum_coverage_errors SimpleCov.minimum_coverage.each do |criterion, expected_percent| - actual = @result.coverage_statistics.fetch(criterion).percent + actual = SimpleCov.round_coverage(@result.coverage_statistics.fetch(criterion).percent) next unless actual < expected_percent key = CRITERION_KEYS.fetch(criterion) From bdbe8b267a096eb853c6f7d6796445aaf2028856 Mon Sep 17 00:00:00 2001 From: Erik Berlin Date: Thu, 16 Apr 2026 16:55:47 -0700 Subject: [PATCH 6/8] Prefer `if` guards over `next` in JSON error formatting Refactor the four error-formatting methods to use `if` guards instead of `next unless ...` inside loops, extract the inner group loop to `format_group_minimum_coverage_errors` to keep method length in check, and disable RuboCop's `Style/Next` cop (which would otherwise enforce the opposite style). --- .rubocop.yml | 4 ++ .../json_formatter/result_hash_formatter.rb | 40 +++++++++++-------- 2 files changed, 27 insertions(+), 17 deletions(-) diff --git a/.rubocop.yml b/.rubocop.yml index 8f0f17b21..4b3377652 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -188,6 +188,10 @@ Metrics/MethodLength: CountComments: false Max: 12 # TODO: Lower to 10 +Style/Next: + Description: Prefer `if` guards over `next` inside loops for readability. + Enabled: false + Metrics/ModuleLength: Description: Avoid modules longer than 100 lines of code. Max: 300 diff --git a/lib/simplecov/formatter/json_formatter/result_hash_formatter.rb b/lib/simplecov/formatter/json_formatter/result_hash_formatter.rb index 04c69e96a..b680216dd 100644 --- a/lib/simplecov/formatter/json_formatter/result_hash_formatter.rb +++ b/lib/simplecov/formatter/json_formatter/result_hash_formatter.rb @@ -51,11 +51,12 @@ def format_errors def format_minimum_coverage_errors SimpleCov.minimum_coverage.each do |criterion, expected_percent| actual = SimpleCov.round_coverage(@result.coverage_statistics.fetch(criterion).percent) - next unless actual < expected_percent - key = CRITERION_KEYS.fetch(criterion) - minimum_coverage = formatted_result[:errors][:minimum_coverage] ||= {} - minimum_coverage[key] = {expected: expected_percent, actual: actual} + if actual < expected_percent + key = CRITERION_KEYS.fetch(criterion) + minimum_coverage = formatted_result[:errors][:minimum_coverage] ||= {} + minimum_coverage[key] = {expected: expected_percent, actual: actual} + end end end @@ -63,12 +64,13 @@ def format_minimum_coverage_by_file_errors SimpleCov.minimum_coverage_by_file.each do |criterion, expected_percent| @result.files.each do |file| actual = SimpleCov.round_coverage(file.coverage_statistics.fetch(criterion).percent) - next unless actual < expected_percent - key = CRITERION_KEYS.fetch(criterion) - by_file = formatted_result[:errors][:minimum_coverage_by_file] ||= {} - criterion_errors = by_file[key] ||= {} - criterion_errors[file.filename] = {expected: expected_percent, actual: actual} + if actual < expected_percent + key = CRITERION_KEYS.fetch(criterion) + by_file = formatted_result[:errors][:minimum_coverage_by_file] ||= {} + criterion_errors = by_file[key] ||= {} + criterion_errors[file.filename] = {expected: expected_percent, actual: actual} + end end end end @@ -76,12 +78,15 @@ def format_minimum_coverage_by_file_errors def format_minimum_coverage_by_group_errors SimpleCov.minimum_coverage_by_group.each do |group_name, minimum_group_coverage| group = @result.groups[group_name] - next unless group + format_group_minimum_coverage_errors(group_name, group, minimum_group_coverage) if group + end + end - minimum_group_coverage.each do |criterion, expected_percent| - actual = SimpleCov.round_coverage(group.coverage_statistics.fetch(criterion).percent) - next unless actual < expected_percent + def format_group_minimum_coverage_errors(group_name, group, minimum_group_coverage) + minimum_group_coverage.each do |criterion, expected_percent| + actual = SimpleCov.round_coverage(group.coverage_statistics.fetch(criterion).percent) + if actual < expected_percent key = CRITERION_KEYS.fetch(criterion) by_group = formatted_result[:errors][:minimum_coverage_by_group] ||= {} group_errors = by_group[group_name] ||= {} @@ -98,11 +103,12 @@ def format_maximum_coverage_drop_errors SimpleCov.maximum_coverage_drop.each do |criterion, max_drop| drop = coverage_drop_for(criterion, last_run) - next unless drop && drop > max_drop - key = CRITERION_KEYS.fetch(criterion) - coverage_drop = formatted_result[:errors][:maximum_coverage_drop] ||= {} - coverage_drop[key] = {maximum: max_drop, actual: drop} + if drop && drop > max_drop + key = CRITERION_KEYS.fetch(criterion) + coverage_drop = formatted_result[:errors][:maximum_coverage_drop] ||= {} + coverage_drop[key] = {maximum: max_drop, actual: drop} + end end end From aabfdaf26129fea9f84abd52de9a344a29986955 Mon Sep 17 00:00:00 2001 From: Erik Berlin Date: Thu, 16 Apr 2026 17:15:03 -0700 Subject: [PATCH 7/8] Extract CoverageViolations module for threshold checks The four ExitCodes::*Check classes and the JSON formatter's `errors` section each reimplemented the same logic: iterate configured thresholds, fetch per-criterion actuals, round via `SimpleCov.round_coverage`, and filter where `actual` fails the threshold. Centralize that in `SimpleCov::CoverageViolations` with four module methods (`minimum_overall`, `minimum_by_file`, `minimum_by_group`, `maximum_drop`). Each returns an array of canonical violation hashes with consistent `:criterion`/`:expected`/`:actual` keys (plus `:filename`/`:project_filename` or `:group_name`/`:maximum` as appropriate). Exit-code checks shrink to thin wrappers that render the violations; the JSON formatter's four error-formatting methods collapse to a single call + map each. --- lib/simplecov.rb | 1 + lib/simplecov/coverage_violations.rb | 88 +++++++++++++++++++ .../exit_codes/maximum_coverage_drop_check.rb | 60 ++----------- .../minimum_coverage_by_file_check.rb | 33 ++----- .../minimum_coverage_by_group_check.rb | 45 ++-------- .../minimum_overall_coverage_check.rb | 28 ++---- .../json_formatter/result_hash_formatter.rb | 75 ++++------------ .../minimum_coverage_by_file_check_spec.rb | 2 +- 8 files changed, 134 insertions(+), 198 deletions(-) create mode 100644 lib/simplecov/coverage_violations.rb diff --git a/lib/simplecov.rb b/lib/simplecov.rb index b829678db..0430b120e 100644 --- a/lib/simplecov.rb +++ b/lib/simplecov.rb @@ -478,6 +478,7 @@ def probably_running_parallel_tests? require_relative "simplecov/configuration" SimpleCov.extend SimpleCov::Configuration require_relative "simplecov/coverage_statistics" +require_relative "simplecov/coverage_violations" require_relative "simplecov/exit_codes" require_relative "simplecov/profiles" require_relative "simplecov/source_file/line" diff --git a/lib/simplecov/coverage_violations.rb b/lib/simplecov/coverage_violations.rb new file mode 100644 index 000000000..77a7b3542 --- /dev/null +++ b/lib/simplecov/coverage_violations.rb @@ -0,0 +1,88 @@ +# frozen_string_literal: true + +module SimpleCov + # Computes coverage threshold violations for a given result. Shared by + # the exit-code checks and the JSON formatter's `errors` section. + # + # Each method returns an array of violation hashes. All percents are + # rounded via `SimpleCov.round_coverage` so downstream consumers don't + # need to round again. + module CoverageViolations + class << self + # @return [Array] {:criterion, :expected, :actual} + def minimum_overall(result, thresholds) + thresholds.filter_map do |criterion, expected| + actual = round(result.coverage_statistics.fetch(criterion).percent) + {criterion: criterion, expected: expected, actual: actual} if actual < expected + end + end + + # @return [Array] {:criterion, :expected, :actual, :filename, :project_filename} + def minimum_by_file(result, thresholds) + thresholds.flat_map do |criterion, expected| + result.files.filter_map { |file| file_minimum_violation(file, criterion, expected) } + end + end + + # @return [Array] {:group_name, :criterion, :expected, :actual} + def minimum_by_group(result, thresholds) + thresholds.flat_map do |group_name, minimums| + group = lookup_group(result, group_name) + group ? group_minimum_violations(group_name, group, minimums) : [] + end + end + + # @return [Array] {:criterion, :maximum, :actual} where `actual` + # is the observed drop (in percentage points) vs. the last run. + def maximum_drop(result, thresholds, last_run: SimpleCov::LastRun.read) + return [] unless last_run + + thresholds.filter_map do |criterion, maximum| + actual = compute_drop(criterion, result, last_run) + {criterion: criterion, maximum: maximum, actual: actual} if actual && actual > maximum + end + end + + private + + def file_minimum_violation(file, criterion, expected) + actual = round(file.coverage_statistics.fetch(criterion).percent) + return unless actual < expected + + { + criterion: criterion, + expected: expected, + actual: actual, + filename: file.filename, + project_filename: file.project_filename + } + end + + def group_minimum_violations(group_name, group, minimums) + minimums.filter_map do |criterion, expected| + actual = round(group.coverage_statistics.fetch(criterion).percent) + {group_name: group_name, criterion: criterion, expected: expected, actual: actual} if actual < expected + end + end + + def lookup_group(result, group_name) + group = result.groups[group_name] + warn "minimum_coverage_by_group: no group named '#{group_name}' exists. Available groups: #{result.groups.keys.join(', ')}" unless group + group + end + + def compute_drop(criterion, result, last_run) + last_coverage_percent = last_run.dig(:result, criterion) + last_coverage_percent ||= last_run.dig(:result, :covered_percent) if criterion == :line + return unless last_coverage_percent + + current = round(result.coverage_statistics.fetch(criterion).percent) + (last_coverage_percent - current).floor(10) + end + + def round(percent) + SimpleCov.round_coverage(percent) + end + end + end +end diff --git a/lib/simplecov/exit_codes/maximum_coverage_drop_check.rb b/lib/simplecov/exit_codes/maximum_coverage_drop_check.rb index b77f3d50f..7484507c7 100644 --- a/lib/simplecov/exit_codes/maximum_coverage_drop_check.rb +++ b/lib/simplecov/exit_codes/maximum_coverage_drop_check.rb @@ -3,26 +3,22 @@ module SimpleCov module ExitCodes class MaximumCoverageDropCheck - MAX_DROP_ACCURACY = 10 - def initialize(result, maximum_coverage_drop) @result = result @maximum_coverage_drop = maximum_coverage_drop end def failing? - return false unless maximum_coverage_drop && last_run - - coverage_drop_violations.any? + violations.any? end def report - coverage_drop_violations.each do |violation| + violations.each do |violation| $stderr.printf( "%s coverage has dropped by %.2f%% since the last time (maximum allowed: %.2f%%).\n", - criterion: violation[:criterion].capitalize, - drop_percent: SimpleCov.round_coverage(violation[:drop_percent]), - max_drop: violation[:max_drop] + criterion: violation.fetch(:criterion).capitalize, + drop_percent: violation.fetch(:actual), + max_drop: violation.fetch(:maximum) ) end end @@ -33,50 +29,8 @@ def exit_code private - attr_reader :result, :maximum_coverage_drop - - def last_run - return @last_run if defined?(@last_run) - - @last_run = SimpleCov::LastRun.read - end - - def coverage_drop_violations - @coverage_drop_violations ||= - compute_coverage_drop_data.select do |achieved| - achieved.fetch(:max_drop) < achieved.fetch(:drop_percent) - end - end - - def compute_coverage_drop_data - maximum_coverage_drop.map do |criterion, percent| - { - criterion: criterion, - max_drop: percent, - drop_percent: drop_percent(criterion) - } - end - end - - def drop_percent(criterion) - drop = last_coverage(criterion) - - SimpleCov.round_coverage( - result.coverage_statistics.fetch(criterion).percent - ) - - # floats, I tell ya. - # irb(main):001:0* 80.01 - 80.0 - # => 0.010000000000005116 - drop.floor(MAX_DROP_ACCURACY) - end - - def last_coverage(criterion) - last_coverage_percent = last_run[:result][criterion] - - # fallback for old file format - last_coverage_percent = last_run[:result][:covered_percent] if !last_coverage_percent && criterion == :line - - last_coverage_percent || 0 + def violations + @violations ||= SimpleCov::CoverageViolations.maximum_drop(@result, @maximum_coverage_drop) end end end diff --git a/lib/simplecov/exit_codes/minimum_coverage_by_file_check.rb b/lib/simplecov/exit_codes/minimum_coverage_by_file_check.rb index 80820b9bb..e41d74c61 100644 --- a/lib/simplecov/exit_codes/minimum_coverage_by_file_check.rb +++ b/lib/simplecov/exit_codes/minimum_coverage_by_file_check.rb @@ -9,17 +9,17 @@ def initialize(result, minimum_coverage_by_file) end def failing? - minimum_violations.any? + violations.any? end def report - minimum_violations.each do |violation| + violations.each do |violation| $stderr.printf( "%s coverage by file (%.2f%%) is below the expected minimum coverage (%.2f%%) in %s.\n", - covered: SimpleCov.round_coverage(violation.fetch(:actual)), - minimum_coverage: violation.fetch(:minimum_expected), + covered: violation.fetch(:actual), + minimum_coverage: violation.fetch(:expected), criterion: violation.fetch(:criterion).capitalize, - filename: violation.fetch(:filename) + filename: violation.fetch(:project_filename) ) end end @@ -30,27 +30,8 @@ def exit_code private - attr_reader :result, :minimum_coverage_by_file - - def minimum_violations - @minimum_violations ||= - compute_minimum_coverage_data.select do |achieved| - achieved.fetch(:actual) < achieved.fetch(:minimum_expected) - end - end - - def compute_minimum_coverage_data - minimum_coverage_by_file.flat_map do |criterion, expected_percent| - result.files.map do |file| - actual_coverage = file.coverage_statistics.fetch(criterion) - { - criterion: criterion, - minimum_expected: expected_percent, - actual: SimpleCov.round_coverage(actual_coverage.percent), - filename: file.project_filename - } - end - end + def violations + @violations ||= SimpleCov::CoverageViolations.minimum_by_file(@result, @minimum_coverage_by_file) end end end diff --git a/lib/simplecov/exit_codes/minimum_coverage_by_group_check.rb b/lib/simplecov/exit_codes/minimum_coverage_by_group_check.rb index d424947a2..bc6179d6a 100644 --- a/lib/simplecov/exit_codes/minimum_coverage_by_group_check.rb +++ b/lib/simplecov/exit_codes/minimum_coverage_by_group_check.rb @@ -9,16 +9,16 @@ def initialize(result, minimum_coverage_by_group) end def failing? - minimum_violations.any? + violations.any? end def report - minimum_violations.each do |violation| + violations.each do |violation| $stderr.printf( "%s coverage by group (%.2f%%) is below the expected minimum coverage (%.2f%%) in %s.\n", group_name: violation.fetch(:group_name), - covered: SimpleCov.round_coverage(violation.fetch(:actual)), - minimum_coverage: violation.fetch(:minimum_expected), + covered: violation.fetch(:actual), + minimum_coverage: violation.fetch(:expected), criterion: violation.fetch(:criterion).capitalize ) end @@ -30,41 +30,8 @@ def exit_code private - attr_reader :result, :minimum_coverage_by_group - - def minimum_violations - @minimum_violations ||= - compute_minimum_coverage_data.select do |achieved| - achieved.fetch(:actual) < achieved.fetch(:minimum_expected) - end - end - - def compute_minimum_coverage_data - minimum_coverage_by_group.flat_map do |group_name, minimum_group_coverage| - group = find_group(group_name) - next [] unless group - - minimum_group_coverage.map do |criterion, expected_percent| - actual_coverage = group.coverage_statistics.fetch(criterion) - minimum_coverage_hash(group_name, criterion, expected_percent, SimpleCov.round_coverage(actual_coverage.percent)) - end - end - end - - def find_group(group_name) - result.groups[group_name] || begin - warn "minimum_coverage_by_group: no group named '#{group_name}' exists. Available groups: #{result.groups.keys.join(', ')}" - nil - end - end - - def minimum_coverage_hash(group_name, criterion, minimum_expected, actual) - { - group_name: group_name, - criterion: criterion, - minimum_expected: minimum_expected, - actual: actual - } + def violations + @violations ||= SimpleCov::CoverageViolations.minimum_by_group(@result, @minimum_coverage_by_group) end end end diff --git a/lib/simplecov/exit_codes/minimum_overall_coverage_check.rb b/lib/simplecov/exit_codes/minimum_overall_coverage_check.rb index ea3a0ea94..c44b31577 100644 --- a/lib/simplecov/exit_codes/minimum_overall_coverage_check.rb +++ b/lib/simplecov/exit_codes/minimum_overall_coverage_check.rb @@ -9,15 +9,15 @@ def initialize(result, minimum_coverage) end def failing? - minimum_violations.any? + violations.any? end def report - minimum_violations.each do |violation| + violations.each do |violation| $stderr.printf( "%s coverage (%.2f%%) is below the expected minimum coverage (%.2f%%).\n", - covered: SimpleCov.round_coverage(violation.fetch(:actual)), - minimum_coverage: violation.fetch(:minimum_expected), + covered: violation.fetch(:actual), + minimum_coverage: violation.fetch(:expected), criterion: violation.fetch(:criterion).capitalize ) end @@ -29,24 +29,8 @@ def exit_code private - attr_reader :result, :minimum_coverage - - def minimum_violations - @minimum_violations ||= calculate_minimum_violations - end - - def calculate_minimum_violations - coverage_achieved = minimum_coverage.map do |criterion, percent| - { - criterion: criterion, - minimum_expected: percent, - actual: result.coverage_statistics.fetch(criterion).percent - } - end - - coverage_achieved.select do |achieved| - achieved.fetch(:actual) < achieved.fetch(:minimum_expected) - end + def violations + @violations ||= SimpleCov::CoverageViolations.minimum_overall(@result, @minimum_coverage) end end end diff --git a/lib/simplecov/formatter/json_formatter/result_hash_formatter.rb b/lib/simplecov/formatter/json_formatter/result_hash_formatter.rb index b680216dd..698fc23a0 100644 --- a/lib/simplecov/formatter/json_formatter/result_hash_formatter.rb +++ b/lib/simplecov/formatter/json_formatter/result_hash_formatter.rb @@ -49,78 +49,39 @@ def format_errors private_constant :CRITERION_KEYS def format_minimum_coverage_errors - SimpleCov.minimum_coverage.each do |criterion, expected_percent| - actual = SimpleCov.round_coverage(@result.coverage_statistics.fetch(criterion).percent) - - if actual < expected_percent - key = CRITERION_KEYS.fetch(criterion) - minimum_coverage = formatted_result[:errors][:minimum_coverage] ||= {} - minimum_coverage[key] = {expected: expected_percent, actual: actual} - end + SimpleCov::CoverageViolations.minimum_overall(@result, SimpleCov.minimum_coverage).each do |violation| + key = CRITERION_KEYS.fetch(violation.fetch(:criterion)) + bucket = formatted_result[:errors][:minimum_coverage] ||= {} + bucket[key] = {expected: violation.fetch(:expected), actual: violation.fetch(:actual)} end end def format_minimum_coverage_by_file_errors - SimpleCov.minimum_coverage_by_file.each do |criterion, expected_percent| - @result.files.each do |file| - actual = SimpleCov.round_coverage(file.coverage_statistics.fetch(criterion).percent) - - if actual < expected_percent - key = CRITERION_KEYS.fetch(criterion) - by_file = formatted_result[:errors][:minimum_coverage_by_file] ||= {} - criterion_errors = by_file[key] ||= {} - criterion_errors[file.filename] = {expected: expected_percent, actual: actual} - end - end + SimpleCov::CoverageViolations.minimum_by_file(@result, SimpleCov.minimum_coverage_by_file).each do |violation| + key = CRITERION_KEYS.fetch(violation.fetch(:criterion)) + bucket = formatted_result[:errors][:minimum_coverage_by_file] ||= {} + criterion_errors = bucket[key] ||= {} + criterion_errors[violation.fetch(:filename)] = {expected: violation.fetch(:expected), actual: violation.fetch(:actual)} end end def format_minimum_coverage_by_group_errors - SimpleCov.minimum_coverage_by_group.each do |group_name, minimum_group_coverage| - group = @result.groups[group_name] - format_group_minimum_coverage_errors(group_name, group, minimum_group_coverage) if group - end - end - - def format_group_minimum_coverage_errors(group_name, group, minimum_group_coverage) - minimum_group_coverage.each do |criterion, expected_percent| - actual = SimpleCov.round_coverage(group.coverage_statistics.fetch(criterion).percent) - - if actual < expected_percent - key = CRITERION_KEYS.fetch(criterion) - by_group = formatted_result[:errors][:minimum_coverage_by_group] ||= {} - group_errors = by_group[group_name] ||= {} - group_errors[key] = {expected: expected_percent, actual: actual} - end + SimpleCov::CoverageViolations.minimum_by_group(@result, SimpleCov.minimum_coverage_by_group).each do |violation| + key = CRITERION_KEYS.fetch(violation.fetch(:criterion)) + bucket = formatted_result[:errors][:minimum_coverage_by_group] ||= {} + group_errors = bucket[violation.fetch(:group_name)] ||= {} + group_errors[key] = {expected: violation.fetch(:expected), actual: violation.fetch(:actual)} end end def format_maximum_coverage_drop_errors - return if SimpleCov.maximum_coverage_drop.empty? - - last_run = SimpleCov::LastRun.read - return unless last_run - - SimpleCov.maximum_coverage_drop.each do |criterion, max_drop| - drop = coverage_drop_for(criterion, last_run) - - if drop && drop > max_drop - key = CRITERION_KEYS.fetch(criterion) - coverage_drop = formatted_result[:errors][:maximum_coverage_drop] ||= {} - coverage_drop[key] = {maximum: max_drop, actual: drop} - end + SimpleCov::CoverageViolations.maximum_drop(@result, SimpleCov.maximum_coverage_drop).each do |violation| + key = CRITERION_KEYS.fetch(violation.fetch(:criterion)) + bucket = formatted_result[:errors][:maximum_coverage_drop] ||= {} + bucket[key] = {maximum: violation.fetch(:maximum), actual: violation.fetch(:actual)} end end - def coverage_drop_for(criterion, last_run) - last_coverage_percent = last_run.dig(:result, criterion) - last_coverage_percent ||= last_run.dig(:result, :covered_percent) if criterion == :line - return nil unless last_coverage_percent - - current = SimpleCov.round_coverage(@result.coverage_statistics.fetch(criterion).percent) - (last_coverage_percent - current).floor(10) - end - def formatted_result @formatted_result ||= { meta: { diff --git a/spec/exit_codes/minimum_coverage_by_file_check_spec.rb b/spec/exit_codes/minimum_coverage_by_file_check_spec.rb index a2a21142f..ffad42c40 100644 --- a/spec/exit_codes/minimum_coverage_by_file_check_spec.rb +++ b/spec/exit_codes/minimum_coverage_by_file_check_spec.rb @@ -11,7 +11,7 @@ let(:coverage_statistics) { {line: SimpleCov::CoverageStatistics.new(covered: 8, missed: 2)} } let(:files) do [ - instance_double(SimpleCov::SourceFile, coverage_statistics: coverage_statistics, project_filename: "/lib/foo.rb") + instance_double(SimpleCov::SourceFile, coverage_statistics: coverage_statistics, filename: "/abs/lib/foo.rb", project_filename: "/lib/foo.rb") ] end From 4916a99afb053d6e61fe00a47c3ccbd0061f5e40 Mon Sep 17 00:00:00 2001 From: Erik Berlin Date: Thu, 16 Apr 2026 17:34:14 -0700 Subject: [PATCH 8/8] Use CoverageStatistics directly in HTML coverage_summary The HTML formatter's `build_stats` reimplemented CoverageStatistics with different key names (`:pct` vs `percent`). Replace it by having `coverage_summary` take a source_file and read from `source_file.coverage_statistics`, and update `enabled_type_summary` to call CoverageStatistics methods directly. Source file ERB template simplifies from hand-building a per-criterion counts hash to a single argument pass-through. --- .../html_formatter/coverage_helpers.rb | 22 +-- .../html_formatter/views/source_file.erb | 6 +- test/html_formatter/test_view_helpers.rb | 183 +++++------------- 3 files changed, 56 insertions(+), 155 deletions(-) diff --git a/lib/simplecov/formatter/html_formatter/coverage_helpers.rb b/lib/simplecov/formatter/html_formatter/coverage_helpers.rb index 9a50c1ea3..a2fc0dfeb 100644 --- a/lib/simplecov/formatter/html_formatter/coverage_helpers.rb +++ b/lib/simplecov/formatter/html_formatter/coverage_helpers.rb @@ -47,21 +47,17 @@ def coverage_type_summary(type, label, summary, enabled:, **opts) enabled_type_summary(type, label, summary.fetch(type.to_sym), opts) end - def coverage_summary(stats, show_method_toggle: false) + def coverage_summary(source_file, show_method_toggle: false) + stats = source_file.coverage_statistics _summary = { - line: build_stats(stats.fetch(:covered_lines), stats.fetch(:total_lines)), - branch: build_stats(stats.fetch(:covered_branches, 0), stats.fetch(:total_branches, 0)), - method: build_stats(stats.fetch(:covered_methods, 0), stats.fetch(:total_methods, 0)), + line: stats[:line], + branch: stats[:branch], + method: stats[:method], show_method_toggle: show_method_toggle } template("coverage_summary").result(binding) end - def build_stats(covered, total) - pct = total.positive? ? (covered * 100.0 / total) : 100.0 - {covered: covered, total: total, missed: total - covered, pct: pct} - end - private def totals_cell_attrs(type, css) @@ -104,12 +100,12 @@ def append_method_attrs(pairs, source_file) end def enabled_type_summary(type, label, stats, opts) - css = coverage_css_class(stats.fetch(:pct)) - missed = stats.fetch(:missed) + css = coverage_css_class(stats.percent) + missed = stats.missed parts = [ %(
\n #{label}: ), - %(#{Kernel.format('%.2f', stats.fetch(:pct).floor(2))}%), - %( #{stats.fetch(:covered)}/#{stats.fetch(:total)} #{opts.fetch(:suffix, 'covered')}) + %(#{Kernel.format('%.2f', stats.percent.floor(2))}%), + %( #{stats.covered}/#{stats.total} #{opts.fetch(:suffix, 'covered')}) ] parts << missed_summary_html(missed, opts.fetch(:missed_class, "red"), opts.fetch(:toggle, false)) if missed.positive? parts << "\n
" diff --git a/lib/simplecov/formatter/html_formatter/views/source_file.erb b/lib/simplecov/formatter/html_formatter/views/source_file.erb index 48f76c416..94a3f7f55 100644 --- a/lib/simplecov/formatter/html_formatter/views/source_file.erb +++ b/lib/simplecov/formatter/html_formatter/views/source_file.erb @@ -1,11 +1,7 @@

<%= shortened_filename source_file %>

- <%= coverage_summary({ - covered_lines: source_file.covered_lines.count, total_lines: source_file.covered_lines.count + source_file.missed_lines.count, - covered_branches: branch_coverage? ? source_file.covered_branches.count : 0, total_branches: branch_coverage? ? source_file.total_branches.count : 0, - covered_methods: method_coverage? ? source_file.covered_methods.count : 0, total_methods: method_coverage? ? source_file.methods.count : 0 - }, show_method_toggle: method_coverage? && source_file.missed_methods.any?) %> + <%= coverage_summary(source_file, show_method_toggle: method_coverage? && source_file.missed_methods.any?) %> <%- if method_coverage? && source_file.missed_methods.any? -%> "), "Expected closing
, got: ...#{result[-30..]}" @@ -1012,7 +918,7 @@ def test_coverage_type_summary_ends_with_closing_div def test_coverage_type_summary_contains_label f = new_formatter_with(branch: false, method: false) - summary = {line: {pct: 100.0, covered: 100, total: 100, missed: 0}} + summary = line_summary(pct: 100.0, covered: 100, total: 100, missed: 0) result = f.send(:coverage_type_summary, "line", "Line coverage", summary, enabled: true) assert_includes result, "Line coverage:" @@ -1020,7 +926,7 @@ def test_coverage_type_summary_contains_label def test_coverage_type_summary_with_missed_includes_missed_count f = new_formatter_with(branch: false, method: false) - summary = {line: {pct: 80.0, covered: 80, total: 100, missed: 20}} + summary = line_summary(pct: 80.0, covered: 80, total: 100, missed: 20) result = f.send(:coverage_type_summary, "line", "Line coverage", summary, enabled: true) assert_includes result, "20 missed" @@ -1028,7 +934,7 @@ def test_coverage_type_summary_with_missed_includes_missed_count def test_coverage_type_summary_no_missed_when_zero f = new_formatter_with(branch: false, method: false) - summary = {line: {pct: 100.0, covered: 100, total: 100, missed: 0}} + summary = line_summary(pct: 100.0, covered: 100, total: 100, missed: 0) result = f.send(:coverage_type_summary, "line", "Line coverage", summary, enabled: true) refute_includes result, "missed" @@ -1036,7 +942,7 @@ def test_coverage_type_summary_no_missed_when_zero def test_coverage_type_summary_missed_uses_default_red_class f = new_formatter_with(branch: false, method: false) - summary = {line: {pct: 80.0, covered: 80, total: 100, missed: 20}} + summary = line_summary(pct: 80.0, covered: 80, total: 100, missed: 20) result = f.send(:coverage_type_summary, "line", "Line coverage", summary, enabled: true) assert_includes result, 'class="red"' @@ -1044,7 +950,7 @@ def test_coverage_type_summary_missed_uses_default_red_class def test_coverage_type_summary_missed_uses_custom_missed_class f = new_formatter_with(branch: false, method: false) - summary = {line: {pct: 80.0, covered: 80, total: 100, missed: 20}} + summary = line_summary(pct: 80.0, covered: 80, total: 100, missed: 20) result = f.send(:coverage_type_summary, "line", "Line coverage", summary, enabled: true, missed_class: "missed-branch-text") assert_includes result, 'class="missed-branch-text"' @@ -1053,7 +959,7 @@ def test_coverage_type_summary_missed_uses_custom_missed_class def test_coverage_type_summary_toggle_false_uses_span f = new_formatter_with(branch: false, method: false) - summary = {line: {pct: 80.0, covered: 80, total: 100, missed: 20}} + summary = line_summary(pct: 80.0, covered: 80, total: 100, missed: 20) result = f.send(:coverage_type_summary, "line", "Line coverage", summary, enabled: true, toggle: false) assert_includes result, ",) @@ -1086,7 +992,7 @@ def test_coverage_type_summary_missed_includes_comma_separator def test_coverage_type_summary_type_appears_in_enabled_div_class f = new_formatter_with(branch: false, method: false) - summary = {branch: {pct: 75.0, covered: 15, total: 20, missed: 5}} + summary = {branch: SimpleCov::CoverageStatistics.new(covered: 15, missed: 5, percent: 75.0)} result = f.send(:coverage_type_summary, "branch", "Branch coverage", summary, enabled: true) assert_includes result, "t-branch-summary" @@ -1229,31 +1135,34 @@ def new_formatter_with(branch: nil, method: nil) def render_summary(covered_lines:, total_lines:) f = SimpleCov::Formatter::HTMLFormatter.new - f.send(:coverage_summary, zero_stats(covered_lines: covered_lines, total_lines: total_lines)) + f.send(:coverage_summary, stub_source_file_with_stats(covered_lines: covered_lines, total_lines: total_lines)) end def full_stats - { - covered_lines: 80, total_lines: 100, - covered_branches: 10, total_branches: 20, - covered_methods: 5, total_methods: 10 - } + stub_source_file_with_stats(covered_lines: 80, total_lines: 100, covered_branches: 10, total_branches: 20, covered_methods: 5, total_methods: 10) end def zero_stats(covered_lines: 0, total_lines: 0) - { - covered_lines: covered_lines, total_lines: total_lines, - covered_branches: 0, total_branches: 0, - covered_methods: 0, total_methods: 0 - } + stub_source_file_with_stats(covered_lines: covered_lines, total_lines: total_lines) end def method_stats - { - covered_lines: 80, total_lines: 100, - covered_branches: 0, total_branches: 0, - covered_methods: 5, total_methods: 10 + stub_source_file_with_stats(covered_lines: 80, total_lines: 100, covered_methods: 5, total_methods: 10) + end + + def stub_source_file_with_stats(covered_lines: 0, total_lines: 0, covered_branches: 0, total_branches: 0, covered_methods: 0, total_methods: 0) # rubocop:disable Metrics/ParameterLists + stats = { + line: SimpleCov::CoverageStatistics.new(covered: covered_lines, missed: total_lines - covered_lines), + branch: SimpleCov::CoverageStatistics.new(covered: covered_branches, missed: total_branches - covered_branches), + method: SimpleCov::CoverageStatistics.new(covered: covered_methods, missed: total_methods - covered_methods) } + obj = Object.new + obj.define_singleton_method(:coverage_statistics) { stats } + obj + end + + def line_summary(pct:, covered:, missed:, **) + {line: SimpleCov::CoverageStatistics.new(covered: covered, missed: missed, percent: pct)} end def make_method_stub(start_line, end_line)