Skip to content

Commit 3175794

Browse files
committed
Align track_files line classification with loaded files
Files added via `SimulateCoverage` (i.e., `track_files` matches that were never required) used to run through `LinesClassifier`, which marks every non-blank, non-comment line as relevant. Ruby's `Coverage` module is smarter: it knows multi-line statements like a `@x = a.foo.bar.baz` method chain are one logical line, that bare `end` keywords aren't relevant, etc. The two paths disagreed, so the same file produced different relevant-line counts depending on whether it was loaded. `SimulateCoverage` now asks `Coverage.line_stub` for the classification the runtime would have produced if the file were required, then overlays `# :nocov:` toggles and `# simplecov:disable line` directive ranges via `LinesClassifier`. `Coverage.line_stub` doesn't know about SimpleCov's exclusion comments, so the overlay demotes those lines to nil; together the two stages produce a stub indistinguishable from the loaded-file result. When `Coverage.line_stub` raises (missing file, parse error) the call falls back to `LinesClassifier` alone. The cucumber fixtures had their expected percentages updated to reflect the (now consistent) classification: untested files no longer count `end` keywords toward "relevant lines", so totals shift accordingly. Resolves #654.
1 parent 9bd3fb3 commit 3175794

6 files changed

Lines changed: 155 additions & 7 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ Unreleased
3636
* `CommandGuesser` now appends the framework name to parallel test data (e.g. `"RSpec (1/2)"` instead of `"(1/2)"`)
3737

3838
## Bugfixes
39+
* Files added via `track_files` that were never loaded now use the same line classification as loaded files. Previously, `SimulateCoverage` ran the file through `LinesClassifier`, which marks every non-blank, non-comment line as relevant — so a multi-line method chain `@x = a.foo.bar` reported 4 relevant lines for the unloaded copy and 2 for the loaded copy, throwing off per-file and overall percentages. `SimulateCoverage` now uses `Coverage.line_stub` (the same stub Ruby would have produced if the file were required), then overlays `# :nocov:` toggles and `# simplecov:disable line` directive ranges that the runtime doesn't know about. The two paths now agree on every shape: multi-line statements, `end` keywords, blank lines, and SimpleCov-specific exclusion comments. Some projects will see their `tracked_files` percentages shift as a result. See #654.
3940
* Fix the parent-process / subprocess race where a Rakefile (or Rails `Bundler.require`) caused `.simplecov` to auto-load `SimpleCov.start` in the rake parent, which then shelled out to a test runner subprocess; the subprocess wrote a correct report, then the parent's `at_exit` would clobber it with an empty 0% report. Three layers of defense now apply: (1) `.simplecov` is treated as configuration only and no longer starts tracking from the parent (see Deprecations); (2) `ResultMerger.store_result` merges incoming entries with same-`command_name` entries that were written after our `process_start_time` instead of overwriting them; (3) `SimpleCov.at_exit_behavior` defers entirely when our merged result is empty and `coverage/.last_run.json` is fresher than this process. See #581.
4041
* Don't report misleading 100% branch/method coverage for files added via `track_files` that were never loaded. See #902
4142
* Fix HTML formatter tab bar layout: dark mode toggle no longer wraps onto two lines, and tabs connect seamlessly with the content panel

features/config_tracked_files.feature

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ Feature:
1919
When I open the coverage report generated with `bundle exec rake test`
2020
Then I should see the groups:
2121
| name | coverage | files |
22-
| All Files | 71.15% | 5 |
22+
| All Files | 77.08% | 5 |
2323

2424
And I should see the source files:
2525
| name | coverage |

features/config_tracked_files_relevant_lines.feature

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,4 +32,4 @@ Feature:
3232
When I open the coverage report generated with `bundle exec rspec spec`
3333
And I follow "lib/not_loaded.rb"
3434
Then the overlay should be open
35-
And I should see "3 relevant lines" within "#source-dialog"
35+
And I should see "2 relevant lines" within "#source-dialog"

features/rspec_rails.feature

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,10 +14,10 @@ Feature:
1414
When I open the coverage report generated with `bundle exec rspec`
1515
Then I should see the groups:
1616
| name | coverage | files |
17-
| All Files | 36.36% | 5 |
17+
| All Files | 50.00% | 5 |
1818
| Controllers | 0.00% | 1 |
1919
| Channels | 100.00% | 0 |
20-
| Models | 50.00% | 2 |
20+
| Models | 60.00% | 2 |
2121
| Mailers | 100.00% | 0 |
2222
| Helpers | 100.00% | 1 |
2323
| Jobs | 0.00% | 1 |

lib/simplecov/simulate_coverage.rb

Lines changed: 35 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,18 +12,51 @@ module SimulateCoverage
1212
# required. Returns the same hash shape as `Coverage.result` (lines,
1313
# branches, methods).
1414
#
15+
# The line classification comes from `Coverage.line_stub` — the same
16+
# classification the runtime would have produced if the file had been
17+
# required — overlaid with SimpleCov's `# :nocov:` toggles and
18+
# `# simplecov:disable line` directive ranges, which `Coverage` doesn't
19+
# know about. This keeps "relevant lines" identical whether a file was
20+
# loaded or just tracked, fixing the multi-line statement discrepancy
21+
# in https://github.com/simplecov-ruby/simplecov/issues/654.
22+
#
1523
# @return [Hash]
1624
#
1725
def call(absolute_path)
18-
lines = File.foreach(absolute_path)
26+
source_lines = read_lines(absolute_path)
27+
lines = coverage_stub(absolute_path, source_lines) ||
28+
LinesClassifier.new.classify(source_lines)
1929

2030
{
21-
"lines" => LinesClassifier.new.classify(lines),
31+
"lines" => lines,
2232
# we don't want to parse branches/methods ourselves...
2333
# requiring files can have side effects and we don't want to trigger that
2434
"branches" => {},
2535
"methods" => {}
2636
}
2737
end
38+
39+
def read_lines(path)
40+
File.readlines(path)
41+
rescue Errno::ENOENT
42+
[]
43+
end
44+
45+
# Combine `Coverage.line_stub` (which gets multi-line statements right)
46+
# with `LinesClassifier` (which knows about `# :nocov:` toggles and
47+
# `# simplecov:disable line` ranges). Returns nil — and the caller
48+
# falls back to `LinesClassifier` alone — when `Coverage` can't read
49+
# or parse the file, or when the runtime doesn't expose `line_stub`
50+
# (JRuby and TruffleRuby).
51+
def coverage_stub(path, source_lines)
52+
return nil unless Coverage.respond_to?(:line_stub)
53+
54+
stub = Coverage.line_stub(path)
55+
classifier_output = LinesClassifier.new.classify(source_lines)
56+
stub.each_index { |idx| stub[idx] = nil if classifier_output[idx].nil? }
57+
stub
58+
rescue Errno::ENOENT, SyntaxError
59+
nil
60+
end
2861
end
2962
end

spec/simulate_coverage_spec.rb

Lines changed: 115 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,26 @@
11
# frozen_string_literal: true
22

33
require "helper"
4+
require "coverage"
5+
require "tempfile"
46

57
RSpec.describe SimpleCov::SimulateCoverage do
68
describe ".call" do
79
let(:fixture) { source_fixture("sample.rb") }
810

11+
# TruffleRuby doesn't implement Coverage.line_stub at all, and JRuby's
12+
# implementation returns the wrong length for multi-line statements.
13+
# The contexts below assert the exact shape line_stub produces and are
14+
# gated accordingly.
15+
has_line_stub = Coverage.respond_to?(:line_stub)
16+
line_stub_handles_multiline = has_line_stub && RUBY_ENGINE == "ruby"
17+
918
it "produces a hash with lines/branches/methods keys" do
1019
result = described_class.call(fixture)
1120
expect(result.keys).to contain_exactly("lines", "branches", "methods")
1221
end
1322

14-
it "classifies the file's lines via LinesClassifier" do
23+
it "classifies the file's lines as an Array" do
1524
result = described_class.call(fixture)
1625
expect(result["lines"]).to be_an(Array)
1726
expect(result["lines"]).not_to be_empty
@@ -22,5 +31,110 @@
2231
expect(result["branches"]).to eq({})
2332
expect(result["methods"]).to eq({})
2433
end
34+
35+
# Regression for https://github.com/simplecov-ruby/simplecov/issues/654.
36+
# A multi-line statement (method chain, hash literal, etc.) used to count
37+
# every continuation line as relevant when the file was tracked but not
38+
# loaded — even though Ruby's Coverage module marks the continuations as
39+
# nil for a loaded file. The two paths now agree.
40+
context "with a multi-line method chain", if: line_stub_handles_multiline do
41+
let(:source) { <<~RUBY }
42+
def show
43+
@product = base_scope
44+
.includes(colors_products: :color)
45+
.find(params[:id])
46+
end
47+
RUBY
48+
49+
it "returns the same line classification Coverage produces for a loaded file" do
50+
with_tmp_source(source) do |path|
51+
# Coverage.line_stub is what Ruby would have produced if the file
52+
# were required — the def + first assignment line are relevant,
53+
# the chained calls and `end` are not.
54+
expect(described_class.call(path)["lines"]).to eq([0, 0, nil, nil, nil])
55+
end
56+
end
57+
end
58+
59+
# Coverage.line_stub doesn't understand SimpleCov's `# :nocov:` toggles,
60+
# so the overlay step must demote those lines to nil.
61+
context "with a :nocov: block", if: has_line_stub do
62+
let(:source) { <<~RUBY }
63+
def shown
64+
1
65+
end
66+
# :nocov:
67+
def hidden
68+
2
69+
end
70+
# :nocov:
71+
RUBY
72+
73+
it "demotes the :nocov: lines (and the toggles themselves) to nil" do
74+
with_tmp_source(source) do |path|
75+
# `def shown` + `1` + `end` for the visible method are relevant;
76+
# everything from the opening :nocov: through the closing one is nil.
77+
expect(described_class.call(path)["lines"]).to eq([0, 0, nil, nil, nil, nil, nil, nil])
78+
end
79+
end
80+
end
81+
82+
# Same overlay path, but with the new `# simplecov:disable line` directive.
83+
context "with a simplecov:disable line range", if: has_line_stub do
84+
let(:source) { <<~RUBY }
85+
def shown
86+
1
87+
end
88+
# simplecov:disable line
89+
def hidden
90+
2
91+
end
92+
# simplecov:enable line
93+
RUBY
94+
95+
it "demotes the disabled range to nil" do
96+
with_tmp_source(source) do |path|
97+
expect(described_class.call(path)["lines"]).to eq([0, 0, nil, nil, nil, nil, nil, nil])
98+
end
99+
end
100+
end
101+
102+
context "when the file does not exist" do
103+
it "returns the empty-shape hash without raising" do
104+
expect(described_class.call("/no/such/file.rb"))
105+
.to eq("lines" => [], "branches" => {}, "methods" => {})
106+
end
107+
end
108+
109+
context "when Coverage.line_stub raises SyntaxError" do
110+
it "falls back to LinesClassifier's raw output" do
111+
allow(Coverage).to receive(:line_stub).and_raise(SyntaxError, "boom")
112+
# With the fallback, every non-blank/non-comment line is relevant —
113+
# the historical (pre-#654) behavior.
114+
with_tmp_source("a = 1\nb = 2\n") do |path|
115+
expect(described_class.call(path)["lines"]).to eq([0, 0])
116+
end
117+
end
118+
end
119+
120+
# Simulates JRuby / TruffleRuby, where Coverage.line_stub doesn't exist.
121+
# Runs on every engine so the fallback branch stays exercised on MRI.
122+
context "when Coverage doesn't expose line_stub" do
123+
it "falls back to LinesClassifier's raw output" do
124+
allow(Coverage).to receive(:respond_to?).and_call_original
125+
allow(Coverage).to receive(:respond_to?).with(:line_stub).and_return(false)
126+
with_tmp_source("a = 1\nb = 2\n") do |path|
127+
expect(described_class.call(path)["lines"]).to eq([0, 0])
128+
end
129+
end
130+
end
131+
132+
def with_tmp_source(content)
133+
Tempfile.create(["sc654", ".rb"]) do |f|
134+
f.write(content)
135+
f.close
136+
yield f.path
137+
end
138+
end
25139
end
26140
end

0 commit comments

Comments
 (0)