Skip to content

Commit dec1550

Browse files
committed
Add :eval_generated filter for ignore_branches and ignore_methods
Rails delegate (and other module_eval / class_eval / instance_eval macros that pass __FILE__ and __LINE__) inject method and branch entries into a file's coverage data even when the static source has nothing at that line. Ruby's Coverage library attributes the eval'd code to the caller's position, so a single delegate call shows up as a missed def and a missed if branch wherever the macro fires. The previous workaround was to disable eval coverage entirely, which loses the legitimate signal. Adds the :eval_generated token to SimpleCov.ignore_branches and adds a new SimpleCov.ignore_methods with the same token. Both filters walk the static source through SimpleCov::StaticCoverageExtractor (Prism) and drop any Coverage tuple whose start line lacks a real def keyword (for methods) or branch construct (for branches). Line presence is the matcher, so a real branch or def that shares a line with an eval-generated entry survives. Prism ships with Ruby 3.3+, and on older Rubies gem install prism enables the filter, otherwise the setting is a no-op. The filters are opt-in and stored regardless of which coverage criteria are enabled at call time, matching the existing ignore_branches lifecycle. Unknown tokens raise SimpleCov::ConfigurationError to catch typos. README and CHANGELOG updated, with specs covering the DSL, the new StaticCoverageExtractor.real_source_positions helper, and SourceFile integration. Resolves #1046.
1 parent f6ec9be commit dec1550

12 files changed

Lines changed: 360 additions & 26 deletions

.rubocop.yml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -55,11 +55,11 @@ Metrics/BlockNesting:
5555
Max: 2
5656

5757
Metrics/ClassLength:
58-
Max: 320
58+
Max: 330
5959

6060
Metrics/ModuleLength:
6161
Description: Avoid modules longer than 100 lines of code.
62-
Max: 420
62+
Max: 440
6363
Exclude:
6464
- "lib/simplecov.rb"
6565

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ Unreleased
5151
* `SimpleCov::Result.new` is roughly 7× faster for already-string-keyed input (the `SimpleCov.collate` hot path). The previous implementation deep-cloned each file's coverage data with `JSON.parse(JSON.dump(coverage))` per source file — a useful normalization for live `Coverage.result` symbol keys, but pure overhead for resultsets loaded from disk that already have string keys. `Result` now stringifies the outer hash keys with `transform_keys` only when needed; the inner branch/method-key shape is already handled by `SourceFile#restore_ruby_data_structure`. See #916.
5252

5353
## Bugfixes
54+
* Added `:eval_generated` tokens to `SimpleCov.ignore_branches` and the new `SimpleCov.ignore_methods` so projects using macros like Rails' `delegate` (or any pattern that calls `module_eval(body, __FILE__, __LINE__)`) can drop the synthetic branch and method entries those macros inject. Ruby's `Coverage` attributes eval'd code to the caller's `__FILE__` / `__LINE__`, so a `delegate :foo, to: :bar` line surfaces as if it had a `def foo` and an `if` branch right there. Detection uses Prism to walk the static source and treats any Coverage entry whose start_line lacks a real `def` keyword (for methods) or branch construct (for branches) as eval-generated. Opt in with `ignore_methods :eval_generated` and / or `ignore_branches :eval_generated`. Prism ships with Ruby 3.3+; on older Rubies `gem install prism` enables the filter, otherwise the setting is a no-op. See #1046.
5455
* Files added via `cover` / `track_files` that were never `require`'d during the run now contribute branch and method entries to the report, not just lines. Previously `SimulateCoverage` left those fields as empty hashes (because parsing source ourselves felt risky), which made unloaded files invisible to the branch and method denominators while their lines DID count — so a `cover "{app,lib}/**/*.rb"` glob over files without specs silently inflated branch% relative to line% (the OP's reproduction was via SonarQube, which surfaces the asymmetry more visibly than the SimpleCov HTML report). Branches and methods are now enumerated statically via `SimpleCov::StaticCoverageExtractor`, which uses Prism to walk the AST and emits Coverage-shaped tuples without loading the file. The shape matches what Ruby's own `Coverage` library reports for the same source: `:if` / `:case` / `:while` / `:until` constructs plus their `:then` / `:else` / `:when` / `:in` / `:body` arms, with the synthetic `:else` for case-without-explicit-else that the `ignore_branches :implicit_else` setting (see Enhancements) targets. Prism is bundled with Ruby 3.3+; on older Rubies `gem install prism` enables the fix, otherwise SimulateCoverage falls back to the previous "empty hashes" behavior. See #1059.
5556
* HTML report: two groups whose names share an alphanumeric suffix but differ only in a leading non-letter (e.g. `">100LOC"` / `"<10LOC"`, or any pair using different special characters) no longer render into the same DOM container. The JS that built HTML ids from group names stripped every non-letter prefix and then every remaining non-alphanumeric char, so both names sanitized to `"LOC"` and the second group silently replaced the first in the rendered tabs. The new encoding (`"g-" + each-non-id-char-as-hex`) preserves uniqueness across all input shapes. See #1038.
5657
* `SimpleCov::Result` now warns when it drops source files because their absolute paths aren't on the local filesystem, instead of silently producing an empty `0 / 0 (100.00%)` report. The most common trigger is `SimpleCov.collate` invoked from a machine or working directory different from where the individual resultsets were generated — when *every* entry is missing the warning explicitly names that case and points at the issue; when only some are missing the warning is quieter and lists up to five paths with a `(+N more)` suffix. See #980.

README.md

Lines changed: 29 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -397,10 +397,35 @@ SimpleCov.start do
397397
end
398398
```
399399

400-
`ignore_branches` is variadic — only `:implicit_else` is currently supported, but the
401-
shape leaves room for future synthetic branch types. Calling it before (or without)
402-
`enable_coverage :branch` is harmless: the setting is stored and applies once branch
403-
coverage is enabled. Explicit `else` arms still count.
400+
`ignore_branches` is variadic. `:implicit_else` and `:eval_generated` (described below)
401+
are the currently supported tokens. Calling it before (or without) `enable_coverage :branch`
402+
is harmless: the setting is stored and applies once branch coverage is enabled.
403+
Explicit `else` arms still count.
404+
405+
### Ignoring eval-generated branches and methods
406+
407+
Rails' `delegate` (and other macros that call `module_eval(body, __FILE__, __LINE__)`)
408+
make Ruby's `Coverage` library attribute the eval'd code to the macro's source line.
409+
The result is a `delegate :foo, to: :bar` line that surfaces in the report as if it
410+
had its own `def foo` and an `if` branch — both reported as missed when the delegated
411+
method isn't called from the suite. Drop those synthetic entries:
412+
413+
```ruby
414+
SimpleCov.start do
415+
enable_coverage :branch
416+
enable_coverage :method
417+
ignore_branches :eval_generated
418+
ignore_methods :eval_generated
419+
end
420+
```
421+
422+
`ignore_methods` is variadic; `:eval_generated` is the only currently supported token.
423+
Both filters detect eval-generated entries by walking the static source with
424+
[Prism](https://github.com/ruby/prism) and dropping any Coverage entry whose start
425+
line lacks a real `def` keyword (for methods) or branch construct (for branches).
426+
Prism is bundled with Ruby 3.3+; on older Rubies `gem install prism` enables the
427+
filter, otherwise it's a silent no-op. Real `def`s and branches that share a line
428+
with an eval-generated entry are kept (line-presence is the matcher).
404429

405430
**Is branch coverage strictly better?** No. Branch coverage really only concerns itself with
406431
conditionals - meaning coverage of sequential code is of no interest to it. A file without

lib/simplecov/configuration.rb

Lines changed: 65 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -807,29 +807,40 @@ def disable_coverage(criterion)
807807
end
808808

809809
# Branch coverage entries that should not count toward the report when
810-
# they appear in the raw `Coverage.result`. The only currently supported
811-
# token is `:implicit_else` — synthetic `else` arms that Ruby's Coverage
812-
# library reports for constructs without a literal `else` keyword (e.g.
813-
# `case/in` without `else`, `case/when` without `else`, `||=`, `&&=`,
814-
# and `if` without `else`). They show up as "missed" branches and
815-
# depress the branch-coverage percentage even though the source has no
816-
# corresponding code to exercise. See #1033.
817-
#
818-
# Variadic — pass one or more tokens. Multiple calls union:
810+
# they appear in the raw `Coverage.result`. Supported tokens:
811+
#
812+
# * `:implicit_else` (see #1033) drops synthetic `else` arms that
813+
# Ruby's Coverage library reports for constructs without a literal
814+
# `else` keyword (`case/in` without `else`, `case/when` without
815+
# `else`, `||=`, `&&=`, `if` without `else`). They show up as
816+
# "missed" branches and depress the branch-coverage percentage
817+
# even though the source has no corresponding code to exercise.
818+
# * `:eval_generated` (see #1046) drops branches whose source range
819+
# does not correspond to a real conditional in the file. Ruby's
820+
# Coverage attributes `module_eval(body, __FILE__, __LINE__)` to
821+
# the calling file/line, so macros like Rails' `delegate` inject
822+
# "missed" entries into otherwise clean source files when
823+
# `enable_coverage :eval` is on. Detection uses Prism to walk the
824+
# real source and treats any Coverage entry whose start_line does
825+
# not coincide with a real branch construct (`if`, `unless`,
826+
# ternary, `case/when`, `case/in`, `while`, `until`) as
827+
# eval-generated.
828+
#
829+
# Variadic. Pass one or more tokens. Multiple calls union:
819830
#
820831
# SimpleCov.start do
821832
# enable_coverage :branch
822-
# ignore_branches :implicit_else
833+
# ignore_branches :implicit_else, :eval_generated
823834
# end
824835
#
825836
# The setting is recorded regardless of whether branch coverage is
826-
# enabled at call time, so call order doesn't matter:
837+
# enabled at call time, so call order doesn't matter.
827838
# `ignore_branches :implicit_else` before `enable_coverage :branch`
828839
# (or vice versa) both apply the filter. If branch coverage is never
829840
# enabled, the stored setting has nothing to filter and produces no
830841
# observable change in the report. Unknown tokens raise
831842
# `SimpleCov::ConfigurationError` immediately to catch typos.
832-
IGNORABLE_BRANCH_TYPES = %i[implicit_else].freeze
843+
IGNORABLE_BRANCH_TYPES = %i[implicit_else eval_generated].freeze
833844

834845
def ignore_branches(*types)
835846
types.each { |type| raise_if_branch_type_unsupported(type) }
@@ -845,6 +856,38 @@ def ignored_branch?(type)
845856
ignored_branches.include?(type)
846857
end
847858

859+
# Method coverage entries that should not count toward the report
860+
# when they appear in the raw `Coverage.result`. The only currently
861+
# supported token is `:eval_generated` (see #1046), which drops
862+
# method entries whose source position does not correspond to a
863+
# real `def` keyword in the file. Macros that synthesize methods
864+
# via `module_eval` / `class_eval` (Rails' `delegate`, ActiveRecord
865+
# associations, `attr_accessor`-style helpers) inject "missed"
866+
# method entries when `enable_coverage :eval` is on. Detection uses
867+
# Prism to walk the real source and treat any Coverage method
868+
# entry whose start_line does not match a real `def` as
869+
# eval-generated.
870+
#
871+
# Variadic. Same lifecycle as `ignore_branches`: setting is recorded
872+
# regardless of whether method coverage is enabled, applies once
873+
# method coverage is enabled, no observable effect if it never is.
874+
# Unknown tokens raise `SimpleCov::ConfigurationError`.
875+
IGNORABLE_METHOD_TYPES = %i[eval_generated].freeze
876+
877+
def ignore_methods(*types)
878+
types.each { |type| raise_if_method_type_unsupported(type) }
879+
ignored_methods.concat(types).uniq!
880+
ignored_methods
881+
end
882+
883+
def ignored_methods
884+
@ignored_methods ||= []
885+
end
886+
887+
def ignored_method?(type)
888+
ignored_methods.include?(type)
889+
end
890+
848891
def primary_coverage(criterion = nil)
849892
if criterion.nil?
850893
@primary_coverage ||= default_primary_coverage
@@ -966,8 +1009,16 @@ def raise_if_branch_type_unsupported(type)
9661009
return if IGNORABLE_BRANCH_TYPES.member?(type)
9671010

9681011
raise SimpleCov::ConfigurationError,
969-
"Unsupported branch type #{type.inspect} for `ignore_branches`; " \
970-
"supported values are #{IGNORABLE_BRANCH_TYPES.inspect}"
1012+
"Unsupported branch type #{type.inspect} for `ignore_branches`. " \
1013+
"Supported values are #{IGNORABLE_BRANCH_TYPES.inspect}"
1014+
end
1015+
1016+
def raise_if_method_type_unsupported(type)
1017+
return if IGNORABLE_METHOD_TYPES.member?(type)
1018+
1019+
raise SimpleCov::ConfigurationError,
1020+
"Unsupported method type #{type.inspect} for `ignore_methods`. " \
1021+
"Supported values are #{IGNORABLE_METHOD_TYPES.inspect}"
9711022
end
9721023

9731024
def minimum_possible_coverage_exceeded(coverage_option)

lib/simplecov/source_file.rb

Lines changed: 58 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
require "ripper"
44
require "set"
55
require_relative "directive"
6+
require_relative "static_coverage_extractor"
67

78
module SimpleCov
89
#
@@ -335,12 +336,36 @@ def build_branches_report
335336
def build_branches
336337
coverage_branch_data = coverage_data["branches"] || {}
337338
branches = coverage_branch_data.flat_map do |condition, coverage_branches|
339+
next [] if eval_generated_condition_to_ignore?(condition)
340+
338341
build_branches_from(condition, coverage_branches)
339342
end
340343

341344
process_skipped_branches(branches)
342345
end
343346

347+
# Detect a Coverage-reported branch condition that originates from
348+
# `eval`/`module_eval`/`class_eval`/`instance_eval` rather than from
349+
# the file's literal source. Coverage attributes such branches to the
350+
# caller's `__FILE__`/`__LINE__`, so a Rails `delegate :foo, to: :bar`
351+
# call surfaces inside the source file as if there were branches at
352+
# the `delegate` line. Prism never sees those branches in the static
353+
# source, so a condition whose start_line isn't in the real-source
354+
# branch set must be eval-generated. Only consulted when the user has
355+
# opted in via `SimpleCov.ignore_branches :eval_generated`. See #1046.
356+
def eval_generated_condition_to_ignore?(condition)
357+
return false unless SimpleCov.ignored_branch?(:eval_generated)
358+
359+
positions = real_source_positions
360+
# simplecov:disable branch — nil branch fires only when Prism is unavailable
361+
return false unless positions
362+
363+
# simplecov:enable branch
364+
365+
_type, _id, start_line, * = restore_ruby_data_structure(condition)
366+
!positions[:branches].include?(start_line)
367+
end
368+
344369
def process_skipped_branches(branches)
345370
chunks = no_cov_chunks + directive_chunks.fetch(:branch)
346371
return branches if chunks.empty?
@@ -511,14 +536,46 @@ def branch_coverage_statistics
511536
end
512537

513538
def build_methods
514-
methods = coverage_data.fetch("methods", {}).map do |info, hit_count|
539+
methods = coverage_data.fetch("methods", {}).filter_map do |info, hit_count|
515540
info = restore_ruby_data_structure(info)
541+
next if eval_generated_method_to_ignore?(info)
542+
516543
SourceFile::Method.new(self, info, hit_count)
517544
end
518545

519546
process_skipped_methods(methods)
520547
end
521548

549+
# See `eval_generated_condition_to_ignore?` for the rationale. Coverage
550+
# reports an eval'd `def` at the eval caller's line and name, so a
551+
# method whose `(name, start_line)` is absent from the real-source
552+
# `def` set is eval-generated. Only consulted when the user has opted
553+
# in via `SimpleCov.ignore_methods :eval_generated`. See #1046.
554+
def eval_generated_method_to_ignore?(info)
555+
return false unless SimpleCov.ignored_method?(:eval_generated)
556+
557+
positions = real_source_positions
558+
# simplecov:disable branch — nil branch fires only when Prism is unavailable
559+
return false unless positions
560+
561+
# simplecov:enable branch
562+
563+
_class_name, name, start_line, * = info
564+
!positions[:methods].include?([name, start_line])
565+
end
566+
567+
# Memoize the Prism-derived set of real source positions (branches at
568+
# which lines, methods at which (name, line) pairs). Returns nil when
569+
# Prism is unavailable on this Ruby (older than 3.3 without the gem)
570+
# or when parsing fails. A nil return makes both eval_generated
571+
# filters short-circuit to "keep everything" — no false drops when
572+
# we can't see the static source clearly.
573+
def real_source_positions
574+
return @real_source_positions if defined?(@real_source_positions)
575+
576+
@real_source_positions = StaticCoverageExtractor.real_source_positions(src.join)
577+
end
578+
522579
def process_skipped_methods(methods)
523580
method_chunks = directive_chunks.fetch(:method)
524581
return methods if method_chunks.empty?

lib/simplecov/static_coverage_extractor.rb

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
# frozen_string_literal: true
22

3+
require "set"
4+
35
begin
46
require "prism"
57
rescue LoadError
@@ -70,6 +72,35 @@ def call(source)
7072
# simplecov:enable line
7173
end
7274

75+
# Summarize a source file's REAL branch and method positions, for the
76+
# `:eval_generated` filter (SimpleCov.ignore_branches /
77+
# SimpleCov.ignore_methods, #1046). Returns a hash:
78+
#
79+
# {
80+
# branches: Set[start_line, ...], # e.g., [3, 12, 20]
81+
# methods: Set[[name, start_line], ...] # e.g., [[:foo, 7], [:bar, 13]]
82+
# }
83+
#
84+
# Branch matching is start_line-only because Coverage's condition type
85+
# vocabulary (`:if`, `:unless`, `:case`, `:while`, `:until`) does not
86+
# always match Prism's emitted type (the existing visitor reports
87+
# `:if` for `unless` and ternary). Coincidental line-sharing between
88+
# a real branch and an eval-generated one will keep both, which is
89+
# an acceptable false-negative for an opt-in filter. Method matching
90+
# uses (name, start_line) since a method name is unique at any line.
91+
#
92+
# Returns nil when Prism is unavailable or parsing fails, signaling
93+
# callers to keep every Coverage entry (no false drops).
94+
def real_source_positions(source)
95+
extracted = call(source)
96+
return nil unless extracted
97+
98+
{
99+
branches: extracted["branches"].keys.to_set { |tuple| tuple[2] },
100+
methods: extracted["methods"].keys.to_set { |tuple| [tuple[1], tuple[2]] }
101+
}
102+
end
103+
73104
# simplecov:disable branch
74105
# The `else` arm (Prism missing) is unreachable on engines where the
75106
# dogfood report runs; the Visitor class only matters when Prism is

spec/configuration_spec.rb

Lines changed: 41 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -842,7 +842,7 @@
842842

843843
it "names the supported tokens in the error message" do
844844
expect { config.ignore_branches :nope }
845-
.to raise_error(SimpleCov::ConfigurationError, /supported values are \[:implicit_else\]/)
845+
.to raise_error(SimpleCov::ConfigurationError, /Supported values are \[:implicit_else, :eval_generated\]/)
846846
end
847847

848848
it "stores the setting even when branch coverage is not enabled" do
@@ -861,6 +861,46 @@
861861
expect(config.coverage_criteria).to include :branch
862862
expect(config.ignored_branch?(:implicit_else)).to be true
863863
end
864+
865+
it "accepts :eval_generated alongside :implicit_else" do
866+
config.ignore_branches :implicit_else, :eval_generated
867+
868+
expect(config.ignored_branch?(:implicit_else)).to be true
869+
expect(config.ignored_branch?(:eval_generated)).to be true
870+
end
871+
end
872+
873+
describe "#ignore_methods" do
874+
it "starts empty" do
875+
expect(config.ignored_methods).to eq []
876+
end
877+
878+
it "records the requested token" do
879+
config.ignore_methods :eval_generated
880+
881+
expect(config.ignored_methods).to eq [:eval_generated]
882+
expect(config.ignored_method?(:eval_generated)).to be true
883+
end
884+
885+
it "deduplicates across calls" do
886+
config.ignore_methods :eval_generated
887+
config.ignore_methods :eval_generated
888+
889+
expect(config.ignored_methods).to eq [:eval_generated]
890+
end
891+
892+
it "raises on an unknown token" do
893+
expect { config.ignore_methods :nope }
894+
.to raise_error(SimpleCov::ConfigurationError,
895+
/Unsupported method type :nope.*Supported values are \[:eval_generated\]/m)
896+
end
897+
898+
it "stores the setting even when method coverage is not enabled" do
899+
expect(config.coverage_criteria).to contain_exactly :line
900+
config.ignore_methods :eval_generated
901+
902+
expect(config.ignored_method?(:eval_generated)).to be true
903+
end
864904
end
865905

866906
describe "#disable_coverage" do

spec/fixtures/eval_generated.rb

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
class EvalHost
2+
def_delegators :receiver, :hello
3+
def initialize(receiver)
4+
receiver ? @receiver = receiver : nil
5+
end
6+
attr_reader :receiver
7+
end

0 commit comments

Comments
 (0)